Skip to content

Commit d08178b

Browse files
mvanhornclaude
andcommitted
feat(react-best-practices): add Server Action & Form pattern rules
Add 5 new rules covering Server Action and Form patterns: - server-useoptimistic: instant UI feedback with useOptimistic (HIGH) - server-action-revalidation: granular cache invalidation (HIGH) - server-action-error-handling: return error state, don't throw (MEDIUM-HIGH) - server-progressive-enhancement: forms that work without JS (MEDIUM) - rerender-useformstatus: pending state without prop drilling (MEDIUM) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9aec8ee commit d08178b

6 files changed

Lines changed: 408 additions & 1 deletion

File tree

skills/react-best-practices/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ metadata:
99

1010
# Vercel React Best Practices
1111

12-
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 64 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
12+
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 69 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
1313

1414
## When to Apply
1515

@@ -61,6 +61,10 @@ Reference these guidelines when:
6161
- `server-serialization` - Minimize data passed to client components
6262
- `server-parallel-fetching` - Restructure components to parallelize fetches
6363
- `server-after-nonblocking` - Use after() for non-blocking operations
64+
- `server-useoptimistic` - Use useOptimistic for instant UI feedback
65+
- `server-action-revalidation` - Granular cache revalidation after mutations
66+
- `server-action-error-handling` - Return error state instead of throwing
67+
- `server-progressive-enhancement` - Forms that work without JavaScript
6468

6569
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
6670

@@ -86,6 +90,7 @@ Reference these guidelines when:
8690
- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive
8791
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
8892
- `rerender-no-inline-components` - Don't define components inside components
93+
- `rerender-useformstatus` - Use useFormStatus for pending state without prop drilling
8994

9095
### 6. Rendering Performance (MEDIUM)
9196

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
title: Use useFormStatus for Pending State Without Prop Drilling
3+
impact: MEDIUM
4+
impactDescription: cleaner form components, no manual pending state tracking
5+
tags: rerender, forms, useFormStatus, pending-state, server-actions
6+
---
7+
8+
## Use useFormStatus for Pending State Without Prop Drilling
9+
10+
**Impact: MEDIUM (cleaner form components, no manual pending state tracking)**
11+
12+
Use `useFormStatus` inside a child component of a `<form>` to access the pending state directly. This avoids threading `isPending` through props or lifting state to coordinate between the form and its submit button.
13+
14+
**Incorrect (prop drilling pending state):**
15+
16+
```tsx
17+
'use client'
18+
19+
import { useState } from 'react'
20+
import { submitOrder } from './actions'
21+
22+
function OrderForm() {
23+
const [isPending, setIsPending] = useState(false)
24+
25+
async function handleSubmit(formData: FormData) {
26+
setIsPending(true)
27+
await submitOrder(formData)
28+
setIsPending(false)
29+
}
30+
31+
return (
32+
<form action={handleSubmit}>
33+
<input name="item" />
34+
<OrderSummary />
35+
<SubmitButton isPending={isPending} />
36+
</form>
37+
)
38+
}
39+
40+
function SubmitButton({ isPending }: { isPending: boolean }) {
41+
return (
42+
<button type="submit" disabled={isPending}>
43+
{isPending ? 'Placing order...' : 'Place Order'}
44+
</button>
45+
)
46+
}
47+
```
48+
49+
**Correct (useFormStatus reads pending state from the form):**
50+
51+
```tsx
52+
import { submitOrder } from './actions'
53+
import { SubmitButton } from './submit-button'
54+
55+
function OrderForm() {
56+
return (
57+
<form action={submitOrder}>
58+
<input name="item" />
59+
<OrderSummary />
60+
<SubmitButton />
61+
</form>
62+
)
63+
}
64+
```
65+
66+
```tsx
67+
'use client'
68+
69+
import { useFormStatus } from 'react-dom'
70+
71+
export function SubmitButton() {
72+
const { pending } = useFormStatus()
73+
74+
return (
75+
<button type="submit" disabled={pending}>
76+
{pending ? 'Placing order...' : 'Place Order'}
77+
</button>
78+
)
79+
}
80+
```
81+
82+
`useFormStatus` reads the status of the parent `<form>`, so the component must be rendered as a child of the form element. It cannot be called in the same component that renders the `<form>`.
83+
84+
**Reuse across forms:**
85+
86+
Because `SubmitButton` doesn't depend on any specific form's props, it works as a shared component across your entire app.
87+
88+
Reference: [React useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
title: Return Error State From Server Actions Instead of Throwing
3+
impact: MEDIUM-HIGH
4+
impactDescription: 60-80% better error UX by keeping forms functional on failure
5+
tags: server, server-actions, error-handling, forms, useActionState
6+
---
7+
8+
## Return Error State From Server Actions Instead of Throwing
9+
10+
**Impact: MEDIUM-HIGH (60-80% better error UX by keeping forms functional on failure)**
11+
12+
When a Server Action encounters a validation or business logic error, return an error object instead of throwing. Thrown errors trigger the nearest `error.tsx` boundary, which replaces the entire UI. Returned errors let you show inline feedback while keeping the form usable.
13+
14+
**Incorrect (throw replaces the form with error.tsx):**
15+
16+
```tsx
17+
'use server'
18+
19+
export async function createAccount(prevState: unknown, formData: FormData) {
20+
const email = formData.get('email') as string
21+
22+
const existing = await db.user.findUnique({ where: { email } })
23+
if (existing) {
24+
// This triggers error.tsx - user loses their form input
25+
throw new Error('Email already exists')
26+
}
27+
28+
await db.user.create({ data: { email } })
29+
return { success: true }
30+
}
31+
```
32+
33+
**Correct (return error state for inline display):**
34+
35+
```tsx
36+
'use server'
37+
38+
type ActionState = { error?: string; success?: boolean }
39+
40+
export async function createAccount(
41+
prevState: ActionState,
42+
formData: FormData
43+
): Promise<ActionState> {
44+
const email = formData.get('email') as string
45+
46+
const existing = await db.user.findUnique({ where: { email } })
47+
if (existing) {
48+
// Form stays intact, error shows inline
49+
return { error: 'An account with this email already exists.' }
50+
}
51+
52+
await db.user.create({ data: { email } })
53+
return { success: true }
54+
}
55+
```
56+
57+
```tsx
58+
'use client'
59+
60+
import { useActionState } from 'react'
61+
import { createAccount } from './actions'
62+
63+
function SignupForm() {
64+
const [state, action, isPending] = useActionState(createAccount, {})
65+
66+
return (
67+
<form action={action}>
68+
<input name="email" type="email" />
69+
{state.error && <p role="alert">{state.error}</p>}
70+
<button type="submit" disabled={isPending}>
71+
{isPending ? 'Creating...' : 'Create Account'}
72+
</button>
73+
</form>
74+
)
75+
}
76+
```
77+
78+
**When to throw vs return:**
79+
80+
- **Return errors** for expected failures: validation, duplicates, rate limits, permissions
81+
- **Throw errors** only for unexpected failures: database connection lost, unrecoverable state. These should hit `error.tsx` because the page genuinely can't function.
82+
83+
Reference: [Next.js Server Action error handling](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#error-handling)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
title: Use Granular Cache Revalidation After Mutations
3+
impact: HIGH
4+
impactDescription: prevents stale data without over-invalidating cache
5+
tags: server, server-actions, revalidation, cache, mutations
6+
---
7+
8+
## Use Granular Cache Revalidation After Mutations
9+
10+
**Impact: HIGH (prevents stale data without over-invalidating cache)**
11+
12+
After a Server Action mutates data, choose the most specific revalidation strategy. Using `revalidatePath('/')` or no revalidation at all are the two most common bugs in Next.js apps.
13+
14+
**Incorrect (invalidates entire cache):**
15+
16+
```tsx
17+
'use server'
18+
19+
import { revalidatePath } from 'next/cache'
20+
21+
export async function updatePost(id: string, data: FormData) {
22+
await db.post.update({ where: { id }, data: { title: data.get('title') } })
23+
24+
// Blows away ALL cached data across the entire app
25+
revalidatePath('/')
26+
}
27+
```
28+
29+
**Correct (invalidates only affected data):**
30+
31+
```tsx
32+
'use server'
33+
34+
import { revalidateTag } from 'next/cache'
35+
36+
export async function updatePost(id: string, data: FormData) {
37+
await db.post.update({ where: { id }, data: { title: data.get('title') } })
38+
39+
// Only invalidates fetches tagged with this post
40+
revalidateTag(`post-${id}`)
41+
}
42+
```
43+
44+
Tag your fetches so revalidation is precise:
45+
46+
```tsx
47+
// In a Server Component or data layer
48+
const post = await fetch(`https://api.example.com/posts/${id}`, {
49+
next: { tags: [`post-${id}`, 'posts'] }
50+
})
51+
```
52+
53+
**Choosing the right strategy:**
54+
55+
| Strategy | Use when | Precision |
56+
|----------|----------|-----------|
57+
| `revalidateTag(tag)` | You tagged your fetch calls | High - only matching fetches |
58+
| `revalidatePath('/posts/[id]')` | Specific page needs fresh data | Medium - all data on that route |
59+
| `revalidatePath('/posts', 'layout')` | Section of the app changed | Low - entire layout subtree |
60+
| `redirect('/posts')` | User should navigate after mutation | N/A - new page fetches fresh |
61+
62+
**Common mistake:** Forgetting to revalidate at all. The mutation succeeds on the server, but the user still sees stale cached data until they hard-refresh.
63+
64+
Reference: [Next.js revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
title: Build Forms That Work Without JavaScript
3+
impact: MEDIUM
4+
impactDescription: forms functional before hydration and with JS disabled
5+
tags: server, server-actions, forms, progressive-enhancement, accessibility
6+
---
7+
8+
## Build Forms That Work Without JavaScript
9+
10+
**Impact: MEDIUM (forms functional before hydration and with JS disabled)**
11+
12+
Use the `action` prop on `<form>` to invoke Server Actions natively. This makes forms work before React hydrates and when JavaScript is disabled, improving both performance and accessibility.
13+
14+
**Incorrect (requires JavaScript to submit):**
15+
16+
```tsx
17+
'use client'
18+
19+
import { createComment } from './actions'
20+
21+
function CommentForm() {
22+
async function handleClick() {
23+
const input = document.getElementById('comment') as HTMLInputElement
24+
await createComment(input.value)
25+
}
26+
27+
return (
28+
<div>
29+
<input id="comment" />
30+
<button onClick={handleClick}>Post</button>
31+
</div>
32+
)
33+
}
34+
```
35+
36+
**Correct (works with or without JavaScript):**
37+
38+
```tsx
39+
import { createComment } from './actions'
40+
41+
function CommentForm() {
42+
return (
43+
<form action={createComment}>
44+
<input name="comment" required />
45+
<button type="submit">Post</button>
46+
</form>
47+
)
48+
}
49+
```
50+
51+
The form submits as a native HTML form before hydration. After hydration, React intercepts the submission and handles it client-side with transition support.
52+
53+
**For actions that need additional data, use hidden inputs:**
54+
55+
```tsx
56+
import { deletePost } from './actions'
57+
58+
function DeleteButton({ postId }: { postId: string }) {
59+
return (
60+
<form action={deletePost}>
61+
<input type="hidden" name="postId" value={postId} />
62+
<button type="submit">Delete</button>
63+
</form>
64+
)
65+
}
66+
```
67+
68+
**Why this matters:**
69+
70+
- Forms work during the gap between page load and hydration
71+
- Users on slow connections or devices can interact immediately
72+
- Screen readers and assistive technologies work with native forms out of the box
73+
- Reduces client-side JavaScript needed for basic form handling
74+
75+
Reference: [React form action](https://react.dev/reference/react-dom/components/form)

0 commit comments

Comments
 (0)