@minhduydev/mdpi 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/template/.pi/VERSION +1 -1
  3. package/dist/template/.pi/extensions/templates-injector.ts +35 -7
  4. package/dist/template/.pi/prompts/INDEX.md +3 -9
  5. package/dist/template/.pi/skills/INDEX.md +39 -8
  6. package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
  7. package/dist/template/.pi/skills/frontend-design/SKILL.md +1 -1
  8. package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
  9. package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
  10. package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
  11. package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
  12. package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
  13. package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
  14. package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
  15. package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
  16. package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
  17. package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
  18. package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
  19. package/dist/template/.pi/skills/v0/SKILL.md +264 -0
  20. package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
  21. package/package.json +1 -1
  22. package/dist/template/.pi/prompts/loop-check.md +0 -87
  23. package/dist/template/.pi/prompts/loop-init.md +0 -157
  24. package/dist/template/.pi/prompts/loop-review.md +0 -90
  25. package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
  26. package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
  27. package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
  28. package/dist/template/.pi/templates/loop-github-action.yml +0 -162
  29. package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
  30. package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
  31. package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
  32. package/dist/template/.pi/templates/loop-state.json +0 -24
  33. package/dist/template/.pi/templates/loop-state.md +0 -98
  34. package/dist/template/.pi/templates/loop-vision.md +0 -110
@@ -0,0 +1,374 @@
1
+ ---
2
+ name: react-hook-form
3
+ description: Use when building forms with React Hook Form v7 and Zod v3. Covers useForm, controlled vs uncontrolled, zodResolver, conditional fields, field arrays, Server Actions integration. MUST load before any form implementation.
4
+ ---
5
+
6
+ # React Hook Form + Zod
7
+
8
+ ## When to Use
9
+
10
+ - Building complex forms with many fields and validation rules
11
+ - Integrating Zod schemas for type-safe form validation
12
+ - Handling conditional fields and dynamic field arrays
13
+ - Optimizing form performance (uncontrolled inputs, minimal re-renders)
14
+ - Integrating forms with Server Actions in Next.js
15
+ - Field-level and form-level validation with custom error messages
16
+
17
+ ## When NOT to Use
18
+
19
+ - Simple forms with 1-2 fields (use plain Server Actions)
20
+ - Read-only data display (no form needed)
21
+ - Forms that must work without JavaScript (use progressive enhancement Server Actions)
22
+
23
+ ## Setup
24
+
25
+ ```bash
26
+ npm install react-hook-form @hookform/resolvers zod
27
+ ```
28
+
29
+ ## Basic Form
30
+
31
+ ```tsx
32
+ 'use client'
33
+
34
+ import { useForm } from 'react-hook-form'
35
+ import { zodResolver } from '@hookform/resolvers/zod'
36
+ import { z } from 'zod'
37
+
38
+ const schema = z.object({
39
+ name: z.string().min(2, 'Name must be at least 2 characters'),
40
+ email: z.string().email('Invalid email address'),
41
+ age: z.coerce.number().min(18, 'Must be 18 or older'),
42
+ })
43
+
44
+ type FormData = z.infer<typeof schema>
45
+
46
+ export function SignupForm() {
47
+ const {
48
+ register,
49
+ handleSubmit,
50
+ formState: { errors, isSubmitting },
51
+ } = useForm<FormData>({
52
+ resolver: zodResolver(schema),
53
+ defaultValues: { name: '', email: '', age: 0 },
54
+ })
55
+
56
+ const onSubmit = async (data: FormData) => {
57
+ await createUser(data) // Server Action or API call
58
+ }
59
+
60
+ return (
61
+ <form onSubmit={handleSubmit(onSubmit)}>
62
+ <div>
63
+ <input {...register('name')} placeholder="Name" />
64
+ {errors.name && <p className="text-red-500">{errors.name.message}</p>}
65
+ </div>
66
+
67
+ <div>
68
+ <input {...register('email')} placeholder="Email" />
69
+ {errors.email && <p className="text-red-500">{errors.email.message}</p>}
70
+ </div>
71
+
72
+ <div>
73
+ <input type="number" {...register('age')} placeholder="Age" />
74
+ {errors.age && <p className="text-red-500">{errors.age.message}</p>}
75
+ </div>
76
+
77
+ <button type="submit" disabled={isSubmitting}>
78
+ {isSubmitting ? 'Submitting...' : 'Sign Up'}
79
+ </button>
80
+ </form>
81
+ )
82
+ }
83
+ ```
84
+
85
+ ## Controlled Components (shadcn/ui + Zod)
86
+
87
+ React Hook Form is uncontrolled by default. Use `Controller` for controlled UI libraries:
88
+
89
+ ```tsx
90
+ import { useForm, Controller } from 'react-hook-form'
91
+ import { zodResolver } from '@hookform/resolvers/zod'
92
+ import {
93
+ Select,
94
+ SelectContent,
95
+ SelectItem,
96
+ SelectTrigger,
97
+ SelectValue,
98
+ } from '@/components/ui/select'
99
+
100
+ const schema = z.object({
101
+ plan: z.enum(['free', 'pro', 'enterprise']),
102
+ })
103
+
104
+ export function PlanForm() {
105
+ const { control, handleSubmit } = useForm({
106
+ resolver: zodResolver(schema),
107
+ })
108
+
109
+ return (
110
+ <form onSubmit={handleSubmit(onSubmit)}>
111
+ <Controller
112
+ name="plan"
113
+ control={control}
114
+ render={({ field }) => (
115
+ <Select onValueChange={field.onChange} value={field.value}>
116
+ <SelectTrigger>
117
+ <SelectValue placeholder="Select a plan" />
118
+ </SelectTrigger>
119
+ <SelectContent>
120
+ <SelectItem value="free">Free</SelectItem>
121
+ <SelectItem value="pro">Pro</SelectItem>
122
+ <SelectItem value="enterprise">Enterprise</SelectItem>
123
+ </SelectContent>
124
+ </Select>
125
+ )}
126
+ />
127
+ </form>
128
+ )
129
+ }
130
+ ```
131
+
132
+ ## Zod Schema Patterns
133
+
134
+ ```tsx
135
+ // Refinement — cross-field validation
136
+ const schema = z.object({
137
+ password: z.string().min(8),
138
+ confirmPassword: z.string(),
139
+ }).refine((data) => data.password === data.confirmPassword, {
140
+ message: "Passwords don't match",
141
+ path: ['confirmPassword'], // Attach error to confirmPassword field
142
+ })
143
+
144
+ // SuperRefine — complex logic
145
+ const schema = z.object({
146
+ email: z.string().email(),
147
+ username: z.string().min(3),
148
+ }).superRefine((data, ctx) => {
149
+ if (data.email === data.username) {
150
+ ctx.addIssue({
151
+ code: z.ZodIssueCode.custom,
152
+ message: 'Email and username must be different',
153
+ path: ['username'],
154
+ })
155
+ }
156
+ })
157
+
158
+ // Coercion — convert string inputs
159
+ z.coerce.number() // "42" → 42
160
+ z.coerce.boolean() // "true" → true
161
+ z.coerce.date() // "2024-01-01" → Date
162
+
163
+ // Preprocess — custom coercion
164
+ z.preprocess((val) => {
165
+ if (typeof val === 'string') return val.trim()
166
+ return val
167
+ }, z.string().min(1))
168
+ ```
169
+
170
+ ## Conditional Fields
171
+
172
+ ```tsx
173
+ const schema = z.discriminatedUnion('accountType', [
174
+ z.object({
175
+ accountType: z.literal('personal'),
176
+ name: z.string().min(2),
177
+ }),
178
+ z.object({
179
+ accountType: z.literal('business'),
180
+ companyName: z.string().min(2),
181
+ vatNumber: z.string().regex(/^[A-Z]{2}\d{8,12}$/),
182
+ }),
183
+ ])
184
+
185
+ type FormData = z.infer<typeof schema>
186
+
187
+ export function AccountForm() {
188
+ const { register, watch, handleSubmit } = useForm<FormData>({
189
+ resolver: zodResolver(schema),
190
+ })
191
+
192
+ const accountType = watch('accountType')
193
+
194
+ return (
195
+ <form onSubmit={handleSubmit(onSubmit)}>
196
+ <select {...register('accountType')}>
197
+ <option value="personal">Personal</option>
198
+ <option value="business">Business</option>
199
+ </select>
200
+
201
+ {accountType === 'personal' && (
202
+ <input {...register('name')} />
203
+ )}
204
+ {accountType === 'business' && (
205
+ <>
206
+ <input {...register('companyName')} />
207
+ <input {...register('vatNumber')} />
208
+ </>
209
+ )}
210
+ </form>
211
+ )
212
+ }
213
+ ```
214
+
215
+ ## Field Arrays (Dynamic Fields)
216
+
217
+ ```tsx
218
+ import { useFieldArray } from 'react-hook-form'
219
+
220
+ const schema = z.object({
221
+ emails: z.array(
222
+ z.object({ value: z.string().email() })
223
+ ).min(1, 'At least one email required'),
224
+ })
225
+
226
+ export function EmailListForm() {
227
+ const { register, control, handleSubmit } = useForm({
228
+ resolver: zodResolver(schema),
229
+ defaultValues: { emails: [{ value: '' }] },
230
+ })
231
+
232
+ const { fields, append, remove } = useFieldArray({
233
+ control,
234
+ name: 'emails',
235
+ })
236
+
237
+ return (
238
+ <form onSubmit={handleSubmit(onSubmit)}>
239
+ {fields.map((field, index) => (
240
+ <div key={field.id}>
241
+ <input {...register(`emails.${index}.value`)} placeholder="Email" />
242
+ <button type="button" onClick={() => remove(index)}>Remove</button>
243
+ </div>
244
+ ))}
245
+ <button type="button" onClick={() => append({ value: '' })}>
246
+ Add Email
247
+ </button>
248
+ </form>
249
+ )
250
+ }
251
+ ```
252
+
253
+ ## Integration with Server Actions
254
+
255
+ React Hook Form can delegate submission to a Server Action:
256
+
257
+ ```tsx
258
+ 'use client'
259
+
260
+ import { useForm } from 'react-hook-form'
261
+ import { zodResolver } from '@hookform/resolvers/zod'
262
+ import { createUser } from './actions'
263
+ import { useActionState } from 'react'
264
+
265
+ const schema = z.object({
266
+ name: z.string().min(2),
267
+ email: z.string().email(),
268
+ })
269
+
270
+ export function UserForm() {
271
+ const [serverState, formAction] = useActionState(createUser, null)
272
+
273
+ const { register, formState: { errors } } = useForm({
274
+ resolver: zodResolver(schema),
275
+ })
276
+
277
+ return (
278
+ <form action={formAction}>
279
+ <input {...register('name')} />
280
+ {errors.name?.message || serverState?.errors?.name?.[0]}
281
+
282
+ <input {...register('email')} />
283
+ {errors.email?.message || serverState?.errors?.email?.[0]}
284
+
285
+ <button type="submit">Create</button>
286
+ </form>
287
+ )
288
+ }
289
+ ```
290
+
291
+ **Decision guide:**
292
+ - **Plain Server Actions** → Simple forms, progressive enhancement needed
293
+ - **React Hook Form** → Complex forms, dynamic fields, client-side validation UX
294
+ - **Combined** → RHF for client UX + Server Action for submission
295
+
296
+ ## Form State Reference
297
+
298
+ ```tsx
299
+ const { formState } = useForm()
300
+
301
+ formState.isDirty // User modified any field
302
+ formState.isValid // All fields pass validation
303
+ formState.isSubmitting // Currently submitting
304
+ formState.isSubmitted // Form was submitted at least once
305
+ formState.isSubmitSuccessful // Last submit succeeded
306
+ formState.errors // Field-level errors object
307
+ formState.dirtyFields // Which fields were modified
308
+ formState.touchedFields // Which fields gained and lost focus
309
+ ```
310
+
311
+ ## Performance: `useForm` Options
312
+
313
+ ```tsx
314
+ const { register } = useForm({
315
+ mode: 'onBlur', // Validate on blur (default: onSubmit)
316
+ reValidateMode: 'onChange', // Re-validate after first submit
317
+ shouldFocusError: true, // Focus first field with error after submit
318
+ criteriaMode: 'all', // Show all validation errors per field
319
+ delayError: 500, // Delay error display (ms) for async validation
320
+ })
321
+ ```
322
+
323
+ ## Debounced Validation (Async)
324
+
325
+ ```tsx
326
+ const schema = z.object({
327
+ username: z.string().min(3).refine(
328
+ async (username) => {
329
+ const available = await checkUsername(username)
330
+ return available
331
+ },
332
+ { message: 'Username is already taken' }
333
+ ),
334
+ })
335
+
336
+ // React Hook Form debounces the refine call
337
+ // Add throttle via watch + useEffect if needed
338
+ ```
339
+
340
+ ## Common Pitfalls
341
+
342
+ | Pitfall | Fix |
343
+ |---------|-----|
344
+ | Mixing `register` and `Controller` for same field | Pick one — use `Controller` for UI libraries, `register` for native inputs |
345
+ | Forgetting `defaultValues` shape | `defaultValues` must match schema shape — otherwise fields are undefined |
346
+ | `watch` in render causing loops | Use `watch` sparingly; prefer `getValues()` in callbacks |
347
+ | `setValue` without `shouldDirty`/`shouldValidate` | `setValue('field', val, { shouldDirty: true, shouldValidate: true })` |
348
+ | Zod `refine` on field that doesn't exist yet | Use `superRefine` and `addIssue` with explicit `path` |
349
+ | Not forwarding `ref` in custom components | Use `React.forwardRef` or Controller for custom inputs |
350
+ | `handleSubmit` not wrapping async handler | Always `async (data) => { await ... }` — unhandled promise rejections crash |
351
+ | `useFieldArray` `key` using index | Always use `field.id` as key (not index) — stable across add/remove |
352
+
353
+ ## When to Use React Hook Form vs Server Actions Only
354
+
355
+ | React Hook Form | Server Actions Only |
356
+ |-----------------|-------------------|
357
+ | Complex validation UX (real-time errors) | Simple forms (2-3 fields) |
358
+ | Dynamic field arrays | Progressive enhancement required |
359
+ | Conditional fields that affect validation | No client-side validation needed |
360
+ | Multi-step wizards | Static forms that rarely change |
361
+ | shadcn Select/DatePicker/Combobox | Native inputs only |
362
+ | Field-level async validation (username check) | Server-only validation |
363
+
364
+ ## Verification
365
+
366
+ - [ ] `zodResolver(schema)` configured — links Zod to RHF
367
+ - [ ] `defaultValues` match the schema structure
368
+ - [ ] All controlled components use `Controller` or `useController`
369
+ - [ ] `useFieldArray` keys use `field.id` (not index)
370
+ - [ ] Conditional fields use `watch` with `z.discriminatedUnion` or `z.union`
371
+ - [ ] `handleSubmit` wraps async function
372
+ - [ ] Field-level errors displayed via `formState.errors`
373
+ - [ ] `isSubmitting` disables submit button during submission
374
+ - [ ] Cross-field validation uses `.refine()` or `.superRefine()` with `path`
@@ -0,0 +1,299 @@
1
+ ---
2
+ name: react-server-actions
3
+ description: Use when building forms, mutations, or data writes in React 19 + Next.js. Covers Server Actions, useActionState, useOptimistic, useFormStatus, progressive enhancement, Zod validation, error handling. MUST load before any form or mutation implementation.
4
+ ---
5
+
6
+ # React Server Actions & Forms (React 19)
7
+
8
+ ## When to Use
9
+
10
+ - Building forms that submit data to the server
11
+ - Handling mutations (create, update, delete) in React 19
12
+ - Adding optimistic updates to improve perceived performance
13
+ - Integrating Zod validation with Server Actions
14
+ - Migrating from API routes or tRPC to Server Actions
15
+ - Implementing progressive enhancement (forms work without JS)
16
+
17
+ ## When NOT to Use
18
+
19
+ - Read-only data fetching (use Server Components, `use()`, or TanStack Query)
20
+ - Client-only state management (use Zustand or context)
21
+ - Non-Next.js React projects without Server Action support
22
+
23
+ ## Core Pattern: Server Action + useActionState
24
+
25
+ ```tsx
26
+ // app/actions.ts
27
+ 'use server'
28
+
29
+ import { z } from 'zod'
30
+ import { revalidatePath } from 'next/cache'
31
+
32
+ const schema = z.object({
33
+ name: z.string().min(2),
34
+ email: z.string().email(),
35
+ })
36
+
37
+ export async function createUser(prevState: unknown, formData: FormData) {
38
+ // 1. Parse and validate
39
+ const raw = Object.fromEntries(formData)
40
+ const parsed = schema.safeParse(raw)
41
+
42
+ if (!parsed.success) {
43
+ return { error: parsed.error.flatten().fieldErrors }
44
+ }
45
+
46
+ // 2. Mutate (database call)
47
+ await db.user.create({ data: parsed.data })
48
+
49
+ // 3. Revalidate and redirect
50
+ revalidatePath('/users')
51
+ return { success: true }
52
+ }
53
+ ```
54
+
55
+ ```tsx
56
+ // app/new/page.tsx
57
+ 'use client'
58
+
59
+ import { useActionState } from 'react'
60
+ import { useFormStatus } from 'react-dom'
61
+ import { createUser } from './actions'
62
+
63
+ function SubmitButton() {
64
+ const { pending } = useFormStatus()
65
+ return (
66
+ <button type="submit" disabled={pending}>
67
+ {pending ? 'Creating...' : 'Create User'}
68
+ </button>
69
+ )
70
+ }
71
+
72
+ export default function NewUserForm() {
73
+ const [state, formAction] = useActionState(createUser, null)
74
+
75
+ return (
76
+ <form action={formAction}>
77
+ <input name="name" required />
78
+ {state?.error?.name && <p>{state.error.name[0]}</p>}
79
+
80
+ <input name="email" type="email" required />
81
+ {state?.error?.email && <p>{state.error.email[0]}</p>}
82
+
83
+ <SubmitButton />
84
+ {state?.success && <p className="text-green-600">User created!</p>}
85
+ </form>
86
+ )
87
+ }
88
+ ```
89
+
90
+ ## Hook Reference
91
+
92
+ ### `useActionState(action, initialState, permalink?)`
93
+
94
+ ```tsx
95
+ const [state, formAction, isPending] = useActionState(action, null)
96
+ ```
97
+
98
+ - Replaces `useFormState` (deprecated in React 19)
99
+ - `state` — return value from your Server Action
100
+ - `formAction` — pass as `<form action={formAction}>`
101
+ - `isPending` — convenient boolean for loading state
102
+ - `permalink` — optional URL for progressive enhancement fallback
103
+
104
+ ### `useFormStatus()`
105
+
106
+ ```tsx
107
+ const { pending, data, method, action } = useFormStatus()
108
+ ```
109
+
110
+ - **Must be used inside a `<form>` child component** — not in the component that renders `<form>`
111
+ - Extract `<SubmitButton>` as a separate component
112
+ - `pending` — true while form is submitting
113
+ - `data` — the FormData being submitted
114
+
115
+ ### `useOptimistic(initialValue, reducer)`
116
+
117
+ ```tsx
118
+ const [optimisticTodos, addOptimistic] = useOptimistic(
119
+ todos,
120
+ (state, newTodo: Todo) => [...state, newTodo]
121
+ )
122
+
123
+ // In event handler:
124
+ addOptimistic({ id: crypto.randomUUID(), text, pending: true })
125
+ await addTodoOnServer(formData)
126
+ ```
127
+
128
+ - Shows UI change immediately, reverts on error
129
+ - `reducer` signature: `(currentState, optimisticValue) => newState`
130
+ - Good for: like counters, comment posting, toggle states
131
+
132
+ ## Zod Integration
133
+
134
+ ```tsx
135
+ 'use server'
136
+
137
+ import { z } from 'zod'
138
+
139
+ const SignupSchema = z.object({
140
+ email: z.string().email('Invalid email'),
141
+ password: z.string().min(8, 'Min 8 characters'),
142
+ age: z.coerce.number().min(18, 'Must be 18+'),
143
+ plan: z.enum(['free', 'pro', 'enterprise']),
144
+ })
145
+
146
+ export async function signup(prev: unknown, formData: FormData) {
147
+ const result = SignupSchema.safeParse(Object.fromEntries(formData))
148
+
149
+ if (!result.success) {
150
+ // Return flattened errors keyed by field
151
+ return {
152
+ errors: result.error.flatten().fieldErrors,
153
+ inputs: Object.fromEntries(formData) // preserve user input
154
+ }
155
+ }
156
+
157
+ await createAccount(result.data)
158
+ return { success: true }
159
+ }
160
+ ```
161
+
162
+ ## Progressive Enhancement
163
+
164
+ Server Actions support HTML form fallback — forms work without JavaScript:
165
+
166
+ ```tsx
167
+ // The form works even if JS fails to load:
168
+ <form action={createUser}>
169
+ <input name="name" required />
170
+ <button type="submit">Submit</button>
171
+ </form>
172
+ ```
173
+
174
+ For the JS-enhanced version, use `useActionState` which wraps the same Server Action.
175
+
176
+ **Requirements for progressive enhancement:**
177
+ - Use native `<form>` and `<button type="submit">`
178
+ - Use `required` attribute for client-side validation
179
+ - All form fields must have `name` attributes
180
+ - Server Action must accept `FormData` as second argument
181
+
182
+ ## Error Handling Pattern
183
+
184
+ ```tsx
185
+ type ActionState = {
186
+ error?: string // General error
187
+ errors?: Record<string, string[]> // Field-level errors
188
+ success?: boolean // Success flag
189
+ data?: unknown // Return data on success
190
+ }
191
+
192
+ // In Server Action:
193
+ try {
194
+ await db.user.create({ data: parsed.data })
195
+ return { success: true }
196
+ } catch (err) {
197
+ if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') {
198
+ return { errors: { email: ['Email already registered'] } }
199
+ }
200
+ return { error: 'Something went wrong. Please try again.' }
201
+ }
202
+ ```
203
+
204
+ ## Redirect After Success
205
+
206
+ ```tsx
207
+ 'use server'
208
+
209
+ import { redirect } from 'next/navigation'
210
+
211
+ export async function createPost(prev: unknown, formData: FormData) {
212
+ const post = await db.post.create({ data: { title: formData.get('title') } })
213
+ revalidatePath('/posts')
214
+ redirect(`/posts/${post.id}`)
215
+ }
216
+ ```
217
+
218
+ **Important**: `redirect()` throws a `NEXT_REDIRECT` error — call it after all mutations. Cannot be inside try/catch.
219
+
220
+ ## Avoiding Common Pitfalls
221
+
222
+ | Pitfall | Fix |
223
+ |---------|-----|
224
+ | `useFormStatus()` in the form component itself | Extract `<SubmitButton>` to its own component |
225
+ | Not calling `revalidatePath` after mutation | Always revalidate the affected path |
226
+ | Using `redirect()` inside try/catch | Move redirect outside try/catch |
227
+ | Passing sensitive data as hidden inputs | Validate on server — never trust client data |
228
+ | Server Action not at top of file | `'use server'` directive must be first line |
229
+ | Mutating in Server Components | Server Components are read-only; use `'use client'` + action |
230
+ | Zod `safeParse` then ignoring errors | Always return errors to the form |
231
+ | Multiple forms on one page sharing action | Each form gets its own action or use a field to discriminate |
232
+
233
+ ## Multiple Actions Per Form
234
+
235
+ ```tsx
236
+ <form>
237
+ <button formAction={saveDraft}>Save Draft</button>
238
+ <button formAction={publish}>Publish</button>
239
+ </form>
240
+ ```
241
+
242
+ Each button can have its own `formAction` pointing to a different Server Action.
243
+
244
+ ## Non-Form Mutations (Calling Actions Programmatically)
245
+
246
+ ```tsx
247
+ // For button clicks, toggles, etc. — import and call:
248
+ 'use client'
249
+
250
+ import { toggleLike } from './actions'
251
+
252
+ function LikeButton({ postId }: { postId: string }) {
253
+ const [optimistic, addOptimistic] = useOptimistic(
254
+ false,
255
+ (_, liked: boolean) => liked
256
+ )
257
+
258
+ return (
259
+ <button
260
+ onClick={async () => {
261
+ addOptimistic(!optimistic)
262
+ await toggleLike(postId)
263
+ }}
264
+ >
265
+ {optimistic ? '❤️' : '🤍'}
266
+ </button>
267
+ )
268
+ }
269
+ ```
270
+
271
+ ## Integration with Other Skills
272
+
273
+ | Skill | Relationship |
274
+ |-------|-------------|
275
+ | `react-hook-form` | Alternative form approach (client-side state) — use when you need complex field interactions or field arrays |
276
+ | `nextjs-cache` | After mutation, `revalidatePath` / `revalidateTag` to invalidate cache |
277
+ | `nextjs-app-router` | Form pages use App Router conventions (loading.tsx for submit state) |
278
+ | `tanstack-query` | For GET/read operations — Server Actions are for mutations only |
279
+
280
+ ## When to Use Server Actions vs API Routes
281
+
282
+ | Use Server Actions for | Use API Routes for |
283
+ |-----------------------|-------------------|
284
+ | Forms with progressive enhancement | Public APIs consumed by external clients |
285
+ | Mutations tightly coupled to UI | Webhooks / third-party callbacks |
286
+ | When you want co-located data flow | When you need cache headers, CORS, streaming |
287
+ | Optimistic updates | File uploads (use `multipart/form-data`) |
288
+
289
+ ## Verification
290
+
291
+ - [ ] `'use server'` is the first line of the action file
292
+ - [ ] Server Action accepts `(prevState, formData)` matching `useActionState` signature
293
+ - [ ] `useFormStatus` is in a child component (not the form itself)
294
+ - [ ] All form fields have `name` attributes (for FormData extraction)
295
+ - [ ] Zod validation returns field-level errors
296
+ - [ ] `revalidatePath` / `revalidateTag` called after mutations
297
+ - [ ] `redirect()` outside try/catch blocks
298
+ - [ ] Progressive enhancement: form works with JS disabled
299
+ - [ ] Optimistic updates use `useOptimistic` with clean revert on error