useActionState

Form submissions need to handle both the happy path and errors gracefully. When validation fails, losing all user input is one of the worst UX patterns—especially for long forms with rich content. useActionState solves this by preserving form state across submissions.

The hook connects a Server Function to form state, maintaining values between renders. When an action returns error data, the form repopulates automatically. When it succeeds, you can redirect or reset.

The Core Pattern

'use client'; import { useActionState } from 'react'; import { useRouter } from 'next/navigation'; export function PostForm({ action, defaultValues, redirectTo }) { const router = useRouter(); const [state, formAction] = useActionState(async (_prev, formData) => { const result = await action(formData); if (result.success) { router.push(redirectTo); return _prev; // Keep previous state during redirect } // Return form data to repopulate inputs return result.formData ?? _prev; }, defaultValues); return ( <form action={formAction}> <Input name="title" defaultValue={state.title} /> <Textarea name="content" defaultValue={state.content} /> <SubmitButton>Save</SubmitButton> </form> ); }

The state flows: user submits → action runs → on error, state updates with returned data → inputs repopulate via defaultValue.

Why defaultValue, Not value

Using defaultValue instead of value is intentional. With value, you'd need onChange handlers and controlled components. With defaultValue, React handles the form natively—inputs are uncontrolled but repopulate when state changes.

Reusing for Create and Edit

The same form component works for both creating and editing:

// Create - empty defaults, createPost action <PostForm action={createPost} defaultValues={{ title: '', content: '', published: false }} redirectTo="/dashboard" /> // Edit - pre-filled with existing data, updatePost bound to slug <PostForm action={updatePost.bind(null, slug)} defaultValues={existingPost} redirectTo={`/dashboard/${slug}`} />

The .bind(null, slug) pattern passes the slug as a curried first argument—the form data becomes the second argument.

Error Display

Show validation errors contextually:

const [state, formAction] = useActionState(...); const [error, setError] = useState<string | null>(null); // In the wrapper function: if (!result.success) { setError(result.error); return result.formData ?? _prev; }

Place error messages near the relevant field or at the form level—wherever makes sense for your UI.

October 29, 2025Updated February 13, 2026342 words