@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.
- package/dist/index.js +1 -1
- package/dist/template/.pi/VERSION +1 -1
- package/dist/template/.pi/extensions/templates-injector.ts +35 -7
- package/dist/template/.pi/prompts/INDEX.md +3 -9
- package/dist/template/.pi/skills/INDEX.md +39 -8
- package/dist/template/.pi/skills/dcp-hygiene/SKILL.md +1 -1
- package/dist/template/.pi/skills/frontend-design/SKILL.md +1 -1
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-advanced.md +88 -15
- package/dist/template/.pi/skills/frontend-design/references/animation/motion-core.md +148 -13
- package/dist/template/.pi/skills/frontend-design/references/shadcn/setup.md +127 -20
- package/dist/template/.pi/skills/nextjs-app-router/SKILL.md +334 -0
- package/dist/template/.pi/skills/nextjs-cache/SKILL.md +262 -0
- package/dist/template/.pi/skills/react-best-practices/SKILL.md +79 -1
- package/dist/template/.pi/skills/react-compiler/SKILL.md +237 -0
- package/dist/template/.pi/skills/react-hook-form/SKILL.md +374 -0
- package/dist/template/.pi/skills/react-server-actions/SKILL.md +299 -0
- package/dist/template/.pi/skills/shadcn-ui/SKILL.md +404 -0
- package/dist/template/.pi/skills/tanstack-query/SKILL.md +330 -0
- package/dist/template/.pi/skills/v0/SKILL.md +264 -0
- package/dist/template/.pi/skills/zustand/SKILL.md +333 -0
- package/package.json +1 -1
- package/dist/template/.pi/prompts/loop-check.md +0 -87
- package/dist/template/.pi/prompts/loop-init.md +0 -157
- package/dist/template/.pi/prompts/loop-review.md +0 -90
- package/dist/template/.pi/skills/loop-audit/SKILL.md +0 -141
- package/dist/template/.pi/skills/loop-cost/SKILL.md +0 -130
- package/dist/template/.pi/skills/loop-engineering/SKILL.md +0 -175
- package/dist/template/.pi/templates/loop-github-action.yml +0 -162
- package/dist/template/.pi/templates/loop-orchestrator.sh +0 -514
- package/dist/template/.pi/templates/loop-orchestrator.test.ts +0 -332
- package/dist/template/.pi/templates/loop-orchestrator.ts +0 -936
- package/dist/template/.pi/templates/loop-state.json +0 -24
- package/dist/template/.pi/templates/loop-state.md +0 -98
- 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
|