Form Handling with useActionState (React 19)
Step 27 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
Our create post form works, but it has no feedback:
- When the user clicks "Create Post", nothing visually happens until the redirect completes
- If validation fails on the server, the error is thrown — the user sees an ugly error page instead of inline feedback
- The submit button doesn't show a "Submitting..." state
In this step, you will:
- Refactor the
createPostserver action to return state instead of throwing errors - Create a Client Component form that uses
useActionStateto manage form state - Add a pending state with
useFormStatusto disable the button while submitting - Show server validation errors inline in the form
Goal: Learn useActionState — the React 19 pattern for forms that need pending states and server-side validation feedback.
Table of Contents
- The Problem with Our Current Form
- What Is useActionState?
- Step 1 — Refactor the Server Action
- Step 2 — Create the Form Component
- Step 3 — Add a Pending State
- Step 4 — Update the Page
- How useActionState Works Under the Hood
- useActionState vs Plain form action
- Verify the Form
- Summary & Key Takeaways
The Problem with Our Current Form
Our current create post page (app/posts/new/page.tsx) passes the server action directly as the form's action:
<form action={createPost}>
This works for the happy path — the post is created and the user is redirected. But there are problems:
- No pending state — the user clicks "Create Post" and has no visual feedback that anything is happening
- Errors crash the page — if the server action throws (missing title, database error), the user sees Next.js's error overlay, not a helpful message
- No way to show inline errors — there's no mechanism to return validation errors from the server and display them next to the form fields
useActionState solves all three problems.
What Is useActionState?
useActionState is a React 19 hook that wraps a server action and returns:
const [state, formAction, isPending] = useActionState(action, initialState)
| Return value | What it is |
|---|---|
state | The current state — whatever the server action returned last |
formAction | A wrapped version of your action — pass this to <form action={formAction}> |
isPending | true while the action is running — use for loading states |
The key idea
Instead of the server action throwing errors, it returns a state object:
// Before (throws):
throw new Error('Title is required')
// After (returns state):
return { error: 'Title is required' }
The form component receives this state through useActionState and can display the error inline.
Step 1 — Refactor the Server Action
The current createPost action throws errors and redirects on success. We need a new version that returns state on errors and still redirects on success.
Important: Do not replace the file. Add the new
createPostWithStatefunction below your existing functions (createPost,deletePost,updatePost). The"use server"directive and all imports must stay at the top of the file.
Add this function to the bottom of app/actions.ts:
// Add this function below your existing createPost, deletePost, and updatePost functions
export async function createPostWithState(
prevState: { error: string } | null,
formData: FormData,
) {
const session = await auth()
if (!session?.user?.email) {
return { error: 'You must be signed in to create a post' }
}
const title = formData.get('title') as string
const content = formData.get('content') as string
if (!title?.trim()) {
return { error: 'Title is required' }
}
if (!content?.trim()) {
return { error: 'Content is required' }
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
})
if (!user) {
return { error: 'User not found' }
}
let post
try {
post = await prisma.post.create({
data: { title: title.trim(), content: content.trim(), authorId: user.id },
})
} catch {
return { error: 'Failed to create post. Please try again.' }
}
redirect(`/posts/${post.id}`)
}
What changed
-
New function, not a replacement — we added
createPostWithStatealongside the existingcreatePost. Your existingdeletePost,updatePost, andcreatePostfunctions stay untouched. -
Function signature — now accepts
(prevState, formData)instead of just(formData). This is required byuseActionState— the first argument is always the previous state. -
Returns errors instead of throwing — each validation check returns
{ error: "..." }instead ofthrow new Error(...). This allows the form component to display the error inline. -
Try/catch around Prisma — database errors are caught and returned as a user-friendly message instead of crashing the page.
-
redirect()still works — on success, we still callredirect(), which throws a special error internally (Next.js handles it as a redirect, not an error). This is fine —redirect()is a supported pattern inside server actions.
The state shape
// State is either null (initial) or { error: string }
type State = { error: string } | null
You could extend this to include field-level errors:
type State = {
error?: string
fieldErrors?: {
title?: string
content?: string
}
} | null
But for our simple form, a single error message is enough.
Step 2 — Create the Form Component
The form needs useActionState, which is a client-side hook. Create app/posts/new/CreatePostForm.tsx:
'use client'
import { useActionState } from 'react'
import { createPostWithState } from '@/app/actions'
import SubmitButton from './SubmitButton'
export default function CreatePostForm() {
const [state, formAction] = useActionState(createPostWithState, null)
return (
<form action={formAction} className="rounded-lg bg-white p-6 shadow-md">
<div className="space-y-4">
{state?.error && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{state.error}
</div>
)}
<div>
<label
htmlFor="title"
className="mb-1 block text-sm font-medium text-gray-700"
>
Title
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-teal-500"
placeholder="Your post title"
/>
</div>
<div>
<label
htmlFor="content"
className="mb-1 block text-sm font-medium text-gray-700"
>
Content
</label>
<textarea
id="content"
name="content"
required
rows={8}
className="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-teal-500"
placeholder="Write your post content..."
/>
</div>
<SubmitButton />
</div>
</form>
)
}
Key details
-
useActionState(createPostWithState, null)— wraps the server action.nullis the initial state (no error on first render). -
action={formAction}— the wrapped action is passed to the form. When the form submits, React callscreatePostWithStateon the server and updatesstatewith the return value. -
Error display — when
state?.erroris truthy, a red error box appears above the fields. This shows server-side validation errors inline. -
SubmitButtonis a separate component — we'll create it next. It needs to be a separate component becauseuseFormStatusonly works inside a<form>, so it must be a child of the form.
Step 3 — Add a Pending State
Create app/posts/new/SubmitButton.tsx:
'use client'
import { useFormStatus } from 'react-dom'
export default function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="rounded bg-teal-600 px-4 py-2 text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
How useFormStatus works
useFormStatus is a React DOM hook that reads the status of the parent <form>. It returns:
const { pending, data, method, action } = useFormStatus()
| Property | What it is |
|---|---|
pending | true while the form is submitting |
data | The FormData being submitted |
method | The HTTP method (usually "POST") |
action | The action function reference |
We only need pending to disable the button and show "Creating..." text.
Why a separate component?
useFormStatus must be called inside a component that is rendered inside a <form>. It reads the parent form's status through React's context. If you tried to use it in the same component where the <form> is defined, it wouldn't have a parent form to read from.
// ❌ Won't work — useFormStatus has no parent <form> to read from
function MyForm() {
const { pending } = useFormStatus() // always false!
return (
<form action={formAction}>
<button disabled={pending}>Submit</button>
</form>
)
}
// ✅ Works — SubmitButton is rendered inside <form>, so it can read its status
function SubmitButton() {
const { pending } = useFormStatus() // reads parent <form>'s status
return <button disabled={pending}>Submit</button>
}
function MyForm() {
return (
<form action={formAction}>
<SubmitButton />
</form>
)
}
Step 4 — Update the Page
Update app/posts/new/page.tsx to use the new form component:
import { auth } from '@/auth'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import CreatePostForm from './CreatePostForm'
export const metadata: Metadata = {
title: 'Create New Post',
description: 'Write and publish a new blog post',
}
export default async function NewPostPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<>
<h2 className="mb-4 text-3xl font-bold text-gray-900">Create New Post</h2>
<p className="mb-8 text-gray-600">Signed in as {session.user?.email}</p>
<CreatePostForm />
</>
)
}
What changed
- The inline
<form>withaction={createPost}is replaced with<CreatePostForm /> - The page stays a Server Component — it checks the session and redirects if not authenticated
- The form is a Client Component — it uses
useActionStatefor interactive features - The Server Component renders instantly (no JavaScript needed for the auth check), and the Client Component hydrates for interactivity
How useActionState Works Under the Hood
1. Form renders with initial state (null)
└── No error shown, button says "Create Post"
2. User fills in the form and clicks "Create Post"
└── React calls formAction(formData)
└── isPending becomes true → button shows "Creating..."
3. React sends the form data to the server
└── Server runs createPostWithState(prevState, formData)
4a. If validation fails:
└── Server returns { error: "Title is required" }
└── isPending becomes false
└── state updates to { error: "Title is required" }
└── Error box appears in the form
4b. If everything succeeds:
└── Server calls redirect(`/posts/${post.id}`)
└── Browser navigates to the new post
The state lifecycle
null → { error: "Title is required" } → { error: "Content is required" } → redirect (on success)
Each form submission replaces the previous state. The form always shows the most recent error.
useActionState vs Plain form action
| Feature | <form action={serverAction}> | <form action={formAction}> with useActionState |
|---|---|---|
| Server Component form | Yes | No — requires Client Component |
| Pending state | No built-in way | isPending from useActionState |
| Error handling | Action must throw (crashes the page) | Action returns state (inline errors) |
| Progressive enhancement | Works without JavaScript | Works without JavaScript (with fallback) |
| Complexity | Simplest | Slightly more complex |
When to use which
- Plain
action={serverAction}— simple forms where you're happy with the redirect-on-success pattern and don't need inline error messages (e.g., a simple delete button) useActionState— forms where you need pending states, inline validation errors, or any kind of feedback after submission
Our delete button stays with the simple pattern (it already has its own confirmation dialog in DeleteButton.tsx). Our create form benefits from useActionState because it needs validation feedback and a pending state.
Verify the Form
-
Start the dev server:
pnpm dev -
Log in and visit
/posts/new -
Test the pending state:
- Fill in the form and click "Create Post"
- The button should briefly show "Creating..." and be disabled
- You should be redirected to the new post
-
Test validation errors:
- To test server-side validation, temporarily remove the
requiredattribute from the inputs - Submit the form with an empty title
- You should see a red error box: "Title is required"
- The form stays on the page — no navigation, no crash
- To test server-side validation, temporarily remove the
-
Test progressive enhancement:
- Disable JavaScript in your browser (DevTools → Settings → Disable JavaScript)
- Submit the form — it should still work (the form submits as a regular POST)
- This is progressive enhancement — the form works both with and without JavaScript
Summary & Key Takeaways
| Concept | Details |
|---|---|
useActionState | React 19 hook — wraps a server action to get [state, formAction, isPending] |
| State pattern | Server action returns { error: string } instead of throwing — enables inline errors |
prevState parameter | First argument of the wrapped action — the previous state value |
useFormStatus | React DOM hook — reads pending from the parent <form> context |
| Separate SubmitButton | useFormStatus must be in a child component of <form>, not the form itself |
| Progressive enhancement | Forms still work without JavaScript — the server action runs via standard form submission |
| Keep both patterns | Use plain action={fn} for simple cases; useActionState when you need feedback |
redirect() in actions | Still works inside useActionState actions — it's a supported pattern |
What is Next
In Step 28, we will add type-safe input validation across the app using Zod — defining schemas once and validating in server actions and API routes.