Skip to content
Chapter 44Lesson 3

useActionState: pending state and the result

React's useActionState hook, the form owner's tool for reading what a Server Action returns and tracking whether it is in flight.

In the last lesson you wired a form to a Server Action with a single prop: <form action={createInvoice}>. On submit, React serializes the inputs into FormData, posts to the action, and runs it. That part works, but the form you built is half-finished on purpose, and it’s broken in three ways you’d never ship:

  1. The action takes about 400ms to round-trip. You click “Save invoice” and nothing on the screen moves, so you click again, and now there are two invoices.
  2. The action returns { ok: false, error: { fieldErrors: { email: ['Enter a valid email address.'] } } }. The user typed a bad email, the server caught it, and the user sees nothing. The form threw the action’s answer away.
  3. They fix the email and resubmit successfully. The old “Enter a valid email address.” should disappear, but there’s no code to clear it.

All three come from the same gap: the form posts to the action but never listens to it. It doesn’t know the action is in flight, and it doesn’t read what comes back. One hook closes all three gaps at once, useActionState . It hands you three things: the latest result the action returned, an in-flight flag, and a bound action you put on the form in place of the raw one.

By the end of this lesson you’ll have the canonical form shape, the exact wiring every form in the rest of the course copies. This is the chapter’s central mechanics lesson, so the wiring is the point, and we’ll build it one piece at a time.

Set one thing aside first. When a submit button buried three components deep needs the pending state, and you don’t want to drill a prop through every layer, that’s a different problem, solved by a different hook, useFormStatus, in the next lesson. The case where you want the screen to update before the server even answers is useOptimistic, two lessons out. This lesson covers the form owner’s hook, the one the form component itself calls.

You can’t call the hook without changing the action, so this comes before anything you render.

When you wrap an action in useActionState, the hook prepends an argument. The action’s first parameter is no longer FormData: it’s the previous state, the value the hook last handed back. FormData slides down to second. The signature you wrote last chapter changes shape:

app/invoices/actions.ts
export async function createInvoice(
formData: FormData,
): Promise<Result<Invoice>> {
// ...parse, authorize, mutate, revalidate, return
}

The shape from the last lesson: the action takes FormData directly, as its only argument.

That’s the whole change to the action. The body is untouched: it still reads the formData argument with Object.fromEntries(formData) and runs it through safeParse, exactly as you built it in the last chapter. The parse, the authorization check, the database write, the revalidation, the returned Result: none of it moves. Only the parameter list grows by one.

So what is prevState for? It’s the result the action returned the last time it ran, or null on the very first render, before any submit. For ordinary create-and-update forms you ignore it, because the new result simply replaces the old one and the action doesn’t care what came before. You’d only read it when the action’s output genuinely depends on its own history, like a multi-step wizard that accumulates answers across submits, the kind of flow the next chapter is built around. For everything in this chapter, treat prevState as a parameter you’re required to declare and free to ignore.

Wiring the hook: state, formAction, isPending

Section titled “Wiring the hook: state, formAction, isPending”

With the action ready, you can call the hook. It returns three things, and the cleanest way to meet them is all at once: see the whole shape, then spend the rest of the lesson using each piece.

const [state, formAction, isPending] = useActionState(createInvoice, null);

The hook comes from React itself: import { useActionState } from 'react';. Hold that detail, because the next lesson’s hook, useFormStatus, imports from react-dom, not react, and mixing them up is a common stumble. useActionState comes from react and gives the form owner its pending state.

It’s also client-only. That’s why the form file carries 'use client' at the top, which it already does, since the last lesson made the form a Client Component to use the action prop. There’s no new directive here, just a reason the existing one has to stay.

The second argument, null, is the initial state, the value state holds before the first submit. For a create form, null is the natural choice, since there’s no result yet. It pairs neatly with how you’ll read errors in a moment: every error check is gated on state?.ok === false, and that comparison is simply false while state is still null, so nothing renders on the first paint. One alternative is worth naming once: an edit form can seed the initial state with the existing record as ok(invoice), so state.data carries the server-confirmed values across repeated saves. That’s a project-chapter concern; this chapter standardizes on null for its first form, and so will you.

There’s a rarely-used third argument, permalink, a URL for the no-JavaScript fallback path. Omit it by default. The progressive-enhancement lesson at the end of the chapter is where it earns a sentence, so don’t reach for it now.

The next line is the one people get wrong first, and the rest of the lesson depends on it. The form must use the bound formAction, not the raw createInvoice:

<form action={createInvoice}>

The raw action runs, but the hook never sees the submit, so state and isPending never update, and the form stays inert.

This is the same point the last lesson made with <form action={fn}>: the function reference is the contract. Here it matters even more. Both createInvoice and formAction run your action, but only formAction reports back to the hook. Hand the form the raw one and the action still fires and the invoice still saves; you just get nothing back, because the hook never learns a submit happened. state stays null, isPending stays false, and you’re back to the broken form from the last lesson with extra steps. The bound action is the whole point of calling the hook.

A teammate wired a form with useActionState, but at runtime the submit button never disables and field errors never appear — even though the invoice still gets saved every time. Which line is the bug?

const [state, formAction, isPending] = useActionState(createInvoice, null);
<form action={createInvoice}>
export async function createInvoice(prevState, formData) {
import { useActionState } from 'react';

Rendering the result: the banner and field errors

Section titled “Rendering the result: the banner and field errors”

Now the form can read what the action sends back. This is where the Result contract from the last chapter pays off, and where one access path is worth getting exactly right, because it’s the detail most people overcomplicate.

Start with the discriminator. state is a discriminated union : { ok: true; data } on success, { ok: false; error } on failure, and it might also be null before the first submit. TypeScript won’t let you touch state.error until you’ve proven you’re on the failure branch, so the gate on every error read is state?.ok === false. Skip it and you get both a compile error and a runtime crash, since null.error and { ok: true }.error are both nonsense. This is the same discriminated-union pattern you built much earlier in the course: the ok tag is what selects the branch.

With that gate, the form-level banner is a single gated paragraph, a plain <p role="alert"> carrying the action’s message:

{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}

Notice what the form does not do here: it doesn’t write any error copy. The action authored the userMessage, which the last chapter made a hard rule. The action owns the human sentence for a business-rule failure (“That invoice number is already in use.”), and the form renders it verbatim. There’s no rephrasing and no “Something went wrong.” fallback invented at the UI layer. If a userMessage is missing, the bug is in the action that forgot to return one, not in the form. The form is a renderer, not an author.

Field errors follow the same discipline, but the access path deserves a closer look. We’ll pull it into a small presentational component so the path lives in exactly one place and the form’s JSX stays readable. Step through it:

// field-error.tsx — presentational, no hook, no 'use client'
export const FieldError = ({
id,
messages,
}: { id: string; messages?: string[] }) => {
if (!messages?.length) return null;
return (
<p id={id} role="alert" className="text-destructive text-sm">
{messages[0]}
</p>
);
};
// call site, inside new-invoice-form.tsx
<FieldError
id={emailErrorId}
messages={state?.ok === false ? state.error.fieldErrors?.email : undefined}
/>

A plain presentational component: an arrow bound to const, with no hook and no client directive. It takes a stable id and an optional messages array. The prop type is string[] because that’s exactly what a field maps to in the result, so the component receives the field’s whole array of messages.

// field-error.tsx — presentational, no hook, no 'use client'
export const FieldError = ({
id,
messages,
}: { id: string; messages?: string[] }) => {
if (!messages?.length) return null;
return (
<p id={id} role="alert" className="text-destructive text-sm">
{messages[0]}
</p>
);
};
// call site, inside new-invoice-form.tsx
<FieldError
id={emailErrorId}
messages={state?.ok === false ? state.error.fieldErrors?.email : undefined}
/>

If there are no messages, render nothing. The ?.length guard handles both an absent array and an empty one in a single check.

// field-error.tsx — presentational, no hook, no 'use client'
export const FieldError = ({
id,
messages,
}: { id: string; messages?: string[] }) => {
if (!messages?.length) return null;
return (
<p id={id} role="alert" className="text-destructive text-sm">
{messages[0]}
</p>
);
};
// call site, inside new-invoice-form.tsx
<FieldError
id={emailErrorId}
messages={state?.ok === false ? state.error.fieldErrors?.email : undefined}
/>

Render the first message. A field’s array can hold several, but one line under the input is the convention, so pick messages[0]. role="alert" announces the error to assistive tech.

// field-error.tsx — presentational, no hook, no 'use client'
export const FieldError = ({
id,
messages,
}: { id: string; messages?: string[] }) => {
if (!messages?.length) return null;
return (
<p id={id} role="alert" className="text-destructive text-sm">
{messages[0]}
</p>
);
};
// call site, inside new-invoice-form.tsx
<FieldError
id={emailErrorId}
messages={state?.ok === false ? state.error.fieldErrors?.email : undefined}
/>

Here’s the path, and the one detail to get right. fieldErrors is a flat map, a Record<string, string[]> the action built from the parse failure. A field name maps directly to its array of strings, so you read fieldErrors?.email and you have the messages. There is no extra .errors step under the field: the field is the array, not an object wrapping one. Optional-chain the map, since it’s absent on success, and let <FieldError> pick the first message itself.

1 / 1

That fourth step is the whole reason this section exists, so here it is plainly. The action built fieldErrors by calling z.flattenError(parsed.error).fieldErrors, a deliberate choice the last chapter argued for, precisely so the value would be a flat Record<string, string[]>. The flat shape is what makes the read short: field name in, array of messages out. fieldErrors is fieldErrors?.fieldName and you’re done. If you ever find yourself reaching for fieldErrors.email.errors or digging into a nested object per field, you’ve reconstructed a shape the last chapter specifically chose not to ship. The field maps straight to the array.

Step back and look at where the strings actually come from, because it clarifies the form’s job. The text of a field error, “Enter a valid email address.”, was authored once, in the Zod schema, when you declared the field’s validation. The action’s parse failure runs that schema, flattenError projects the messages into the flat map, and the form reads the map and renders. One set of strings flows through three layers: the schema authors them, the action projects them, the form renders them. The form never invents copy. It’s the last stop, not the source.

There’s one accessibility detail the project will inherit. A field with a possible error should tell assistive technology two things: that it’s currently invalid, and where to find the message. That’s aria-invalid on the input and aria-describedby pointing at the FieldError’s id. The IDs have to be stable across the server render and the client hydration, so you mint them with React’s useId() hook rather than hardcoding strings. You’ll see this on the email field in the assembled form below, and the earlier accessibility lesson covers the full rationale.

Symptom 3 from the opening, stale errors lingering after a fix, turns out to need no code at all. The next submit replaces state with a fresh Result. If that submit succeeds, state.ok is now true, and every block gated on state?.ok === false simply stops rendering. The old field error and the old banner vanish because the condition that drew them is gone. There’s no manual reset to write: the new result clearing the old one is just how the hook works.

Pending state: disabling the button and stopping double-submits

Section titled “Pending state: disabling the button and stopping double-submits”

isPending is the smallest of the three returns, and it resolves the very first symptom. It’s true from the instant React invokes the action until the Result comes back. Wire it to the submit button:

<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>

That single disabled={isPending} is also your double-submit fix, and it’s automatic. A disabled button doesn’t fire its click, so the impatient second click, the one that created a second invoice, lands on a button that’s already disabled while the first submit is in flight. React swallows it. The label swap from “Save invoice” to “Saving…” tells the user why the button went quiet, which is the other half of the fix: the screen finally moves when they click.

To freeze the entire form during submit, every input and not just the button, wrap the controls in <fieldset disabled={isPending}>. A disabled fieldset disables every form control inside it in one stroke. It’s optional, since disabling the submit button is the minimum that prevents the double-submit. Reach for the fieldset when half-edited inputs mid-flight would confuse the user.

Be clear-eyed about the limit, though. isPending stops the double-click race, one user with one impatient finger. It does nothing for a network retry, a refresh-and-resubmit, or two browser tabs. For mutations that must never run twice because they cost money or send mail, the idempotency-key pattern from the last chapter is still the real defense. isPending is a UX guard, not a correctness guarantee.

The diagram below is the temporal model. Drag through it to see exactly when each of the three returns changes across one submit. They aren’t three independent values; they’re a small state machine moving over time.

new-invoice-form idle
Customer email ada@
Total 240.00
useActionState returns phase 1 · idle
state null
isPending false

No result yet — the initial state. Nothing is in flight.

Before any submit, state is the initial null and the form sits idle.

Idle. state = null and isPending = false. The button reads 'Save invoice' and is enabled; the user has filled the fields but not submitted.
new-invoice-form submitting
Customer email ada@
Total 240.00
useActionState returns phase 2 · submit
state null
isPending true

isPending → true. That one flip disables the button.

The submit fires the bound formAction; isPending turns true and the button goes quiet.

Submit clicked. React serializes the named inputs into FormData and calls the bound action. isPending flips to true, so the button disables and reads 'Saving…'.
new-invoice-form in flight
Customer email ada@
Total 240.00
useActionState returns phase 3 · server
state null
isPending true

POST in flight. state waits — the action hasn't returned.

While the action runs, only isPending is true; state holds its old value until a Result comes back.

The action runs on the server — the POST is in flight. isPending is still true, and state has not changed yet because the action hasn't returned its Result.
new-invoice-form rejected
Customer email ada@
Enter a valid email address.
Total 240.00
useActionState returns phase 4 · failure
state { ok: false, error }
isPending false

state now carries the failure; the typed values persist.

Failure: isPending is false again, state.ok === false draws the field error, and nothing the user typed is lost.

The Result returns a failure. isPending flips back to false and state becomes { ok: false, error }. The field error renders, and the values the user typed stay in the inputs.
new-invoice-form saved
Customer email customer@example.com
Total 0.00
useActionState returns phase 5 · success
state { ok: true, data }
isPending false

Success: ok === false blocks stop rendering, so the error is gone.

Success swaps state to { ok: true }: the error vanishes on its own and the uncontrolled form clears, ready for the next entry.

The user fixes the email and resubmits. isPending flips true then false again; state becomes { ok: true, data }. The old error clears and the uncontrolled form resets to blank.

Step 4 is worth pausing on, because it’s a behavior you get for free from the last lesson. Recall that React auto-resets an uncontrolled form on success. On failure it does the opposite: it leaves the inputs alone, and the values the user typed stay put. That’s exactly what you want, since the email was rejected, so the user fixes that one field and resubmits without retyping the other five. The failure path keeping the data isn’t an accident; it’s the platform matching the obvious UX.

There’s a sibling case: keeping the typed values after a successful save, the way an edit form should. That needs state.data fed back as defaultValue. The last lesson promised this fix would land here, but it’s a small enough piece that the project chapter writes it in full, on a real edit form. For now, remember that success-keeps-values is an edit-form move, and state.data is the lever.

Now assemble the pieces into one component, the shape the rest of the course copies. Read it as a whole first, then step through the parts:

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

The form is a Client Component, because useActionState is a client-only hook. The directive sits at the top of the file.

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

One hook owns all three concerns, state, the bound formAction, and isPending, initialized with null, meaning no result yet.

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

The bound action goes on the form, never the raw createInvoice. This is the wire that connects the form to the hook’s state machine.

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

The form-level error: gated on ok === false, rendering the action’s userMessage verbatim. The form authors no copy of its own.

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

The email field plus its FieldError. emailErrors is read once at the top, on lines 10-11, using the flat fieldErrors?.email path guarded by the discriminator. The name is the contract with the schema, the same string the action parses. aria-invalid and aria-describedby, with a useId() id, wire the field to its message for assistive tech.

'use client';
import { useActionState, useId } from 'react';
import { createInvoice } from './actions';
import { FieldError } from './field-error';
export const NewInvoiceForm = () => {
const [state, formAction, isPending] = useActionState(createInvoice, null);
const emailErrorId = useId();
const emailErrors =
state?.ok === false ? state.error.fieldErrors?.email : undefined;
return (
<form action={formAction} className="space-y-4">
{state?.ok === false && (
<p role="alert" className="text-destructive text-sm">
{state.error.userMessage}
</p>
)}
<label className="block">
Customer email
<input
name="email"
type="email"
aria-invalid={Boolean(emailErrors)}
aria-describedby={emailErrorId}
/>
</label>
<FieldError id={emailErrorId} messages={emailErrors} />
<label className="block">
Total
<input name="total" type="number" step="0.01" />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save invoice'}
</button>
</form>
);
};

The submit button is driven by isPending: disabled while in flight, with a label that swaps to ‘Saving…’. The disabled state is also the double-submit guard.

1 / 1

That component is the deliverable of this lesson: every form in the rest of the course is this shape. The project chapter extends it with more fields, a reusable submit button factored out in the next lesson, and optimistic updates layered on in the lesson after, but the skeleton never changes. It’s a Client Component, with the hook at the root seeded with null, the bound action on the form, the banner gated on the discriminator, named inputs each with a FieldError reading the flat map, and a submit button driven by isPending. Learn this one shape and you’ve learned the chapter.

To fix the lifecycle in memory, order the steps below as the form actually experiences a submit, top to bottom. The canonical form sits fixed above the steps for reference:

Order the steps a single form submit goes through, from the click to the re-render. Drag the items into the correct order, then press Check.

const [state, formAction, isPending] = useActionState(createInvoice, null);
// ...
<form action={formAction}>
<button type="submit" disabled={isPending}>{isPending ? 'Saving…' : 'Save invoice'}</button>
</form>
The user clicks the submit button.
React serializes the form’s named inputs into a FormData object.
isPending flips to true and the button disables.
The bound action runs as (prevState, formData) on the server.
The action returns a Result.
state updates to the new Result and isPending flips back to false.
The form re-renders — showing errors, or resetting on success.

The syntax here is load-bearing, and wiring it once by hand sticks better than reading it. To make every connection visible the instant the form paints, this form picks up mid-story: the user already submitted once with a bad email and the server rejected it, so the form re-renders with that prior failure as its initial state. This is the seeded-state move the edit-form aside mentioned, in miniature. Everything you need to render is already in state from the first paint, so no submit is required to see your wiring work. The same four connections from the canonical shape apply.

This profile form re-rendered after a rejected save, so its initial state is the prior failure (the previousResult constant). Wire four things: (1) call useActionState(submitProfile, previousResult) and destructure all three returns; (2) put the bound formAction on the <form>; (3) drive the submit button with isPending — disabled while pending, label swapping to 'Saving…'; (4) render the email field's error under the input by reading state.error.fieldErrors?.email?.[0], guarded by state?.ok === false. The seeded failure means a correct read shows the error on the very first paint.

Preview

    If you get stuck, the four tasks map one-to-one onto the canonical form above: the hook call, seeded here with previousResult instead of null, the action={formAction} swap, the disabled={isPending} button, and the guarded fieldErrors?.email?.[0] read.

    The hook has a few more corners, like the permalink argument and edge cases around prevState, that the official reference covers in full. You won’t need them for the forms in this course, but it’s the canonical source when you do.

    React reference — useActionState
    react.dev

    The official API reference, including the edge cases this lesson set aside.

    The hook is the same everywhere, but seeing it inside the full stack, with Server Actions, Zod validation, and the progressive-enhancement story, is where it clicks. These four pick up where the lesson stops.