Form Handling with useActionState (React 19)

Step 27 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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:

  1. Refactor the createPost server action to return state instead of throwing errors
  2. Create a Client Component form that uses useActionState to manage form state
  3. Add a pending state with useFormStatus to disable the button while submitting
  4. 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

  1. The Problem with Our Current Form
  2. What Is useActionState?
  3. Step 1 — Refactor the Server Action
  4. Step 2 — Create the Form Component
  5. Step 3 — Add a Pending State
  6. Step 4 — Update the Page
  7. How useActionState Works Under the Hood
  8. useActionState vs Plain form action
  9. Verify the Form
  10. Summary & Key Takeaways

The Problem with Our Current Form

↑ Index

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:

  1. No pending state — the user clicks "Create Post" and has no visual feedback that anything is happening
  2. 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
  3. 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?

↑ Index

useActionState is a React 19 hook that wraps a server action and returns:

const [state, formAction, isPending] = useActionState(action, initialState)
Return valueWhat it is
stateThe current state — whatever the server action returned last
formActionA wrapped version of your action — pass this to <form action={formAction}>
isPendingtrue 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

↑ Index

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 createPostWithState function 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

  1. New function, not a replacement — we added createPostWithState alongside the existing createPost. Your existing deletePost, updatePost, and createPost functions stay untouched.

  2. Function signature — now accepts (prevState, formData) instead of just (formData). This is required by useActionState — the first argument is always the previous state.

  3. Returns errors instead of throwing — each validation check returns { error: "..." } instead of throw new Error(...). This allows the form component to display the error inline.

  4. Try/catch around Prisma — database errors are caught and returned as a user-friendly message instead of crashing the page.

  5. redirect() still works — on success, we still call redirect(), 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

↑ Index

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

  1. useActionState(createPostWithState, null) — wraps the server action. null is the initial state (no error on first render).

  2. action={formAction} — the wrapped action is passed to the form. When the form submits, React calls createPostWithState on the server and updates state with the return value.

  3. Error display — when state?.error is truthy, a red error box appears above the fields. This shows server-side validation errors inline.

  4. SubmitButton is a separate component — we'll create it next. It needs to be a separate component because useFormStatus only works inside a <form>, so it must be a child of the form.


Step 3 — Add a Pending State

↑ Index

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()
PropertyWhat it is
pendingtrue while the form is submitting
dataThe FormData being submitted
methodThe HTTP method (usually "POST")
actionThe 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

↑ Index

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> with action={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 useActionState for 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

↑ Index

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

↑ Index

Feature<form action={serverAction}><form action={formAction}> with useActionState
Server Component formYesNo — requires Client Component
Pending stateNo built-in wayisPending from useActionState
Error handlingAction must throw (crashes the page)Action returns state (inline errors)
Progressive enhancementWorks without JavaScriptWorks without JavaScript (with fallback)
ComplexitySimplestSlightly 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

↑ Index

  1. Start the dev server: pnpm dev

  2. Log in and visit /posts/new

  3. 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
  4. Test validation errors:

    • To test server-side validation, temporarily remove the required attribute 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
  5. 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

↑ Index

ConceptDetails
useActionStateReact 19 hook — wraps a server action to get [state, formAction, isPending]
State patternServer action returns { error: string } instead of throwing — enables inline errors
prevState parameterFirst argument of the wrapped action — the previous state value
useFormStatusReact DOM hook — reads pending from the parent <form> context
Separate SubmitButtonuseFormStatus must be in a child component of <form>, not the form itself
Progressive enhancementForms still work without JavaScript — the server action runs via standard form submission
Keep both patternsUse plain action={fn} for simple cases; useActionState when you need feedback
redirect() in actionsStill 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.