Skip to content
Chapter 44Lesson 3

useActionState: pending state and the result

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

Last lesson you wired a form to a Server Action with one 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 is broken in three ways you’d never ship:

  1. The action takes about 400ms to round-trip. You click “Save invoice,” nothing on 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 server caught the bad email, but the user sees nothing: the form threw the answer away.
  3. They fix the email and resubmit successfully, yet the old error stays on screen, because no code clears 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, useActionState : it hands you 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.

Two related needs have their own hooks in later lessons: reading pending state from a deeply nested button (useFormStatus) and updating the screen before the server answers (useOptimistic).

You can’t call the hook without first changing the action, so start here.

useActionState prepends an argument to the action. The first parameter is no longer FormData: it’s the previous state, the value the hook last returned. FormData moves to second:

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

Last lesson: the action takes FormData as its only argument.

The body doesn’t move. It still reads the formData argument, runs safeParse, authorizes, writes, revalidates, and returns a Result, exactly as before. Only the parameter list grows by one.

prevState is the result the action returned last time, or null on the first render. Create-and-update forms ignore it: the new result replaces the old one, and the action doesn’t care what came before. You read it only when the output depends on its own history, such as a multi-step wizard that accumulates answers across submits. For this chapter, declare prevState and ignore it.

Wiring the hook: state, formAction, isPending

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

With the action ready, call the hook. It returns three things, so meet them all at once:

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, and mixing the two up is a common stumble.

It’s also client-only, which is why the form file needs 'use client' at the top. It already has it: last lesson made the form a Client Component to use the action prop.

The second argument, null, is the initial state, the value state holds before the first submit. For a create form there’s no result yet, so null is the natural choice. It pairs with how you’ll read errors in a moment: every error check is gated on state?.ok === false, which is false while state is null, so nothing renders on first paint. An edit form would instead seed the initial state with the existing record as ok(invoice), a project-chapter concern; this chapter standardizes on null.

A rarely-used third argument, permalink, sets the URL for the no-JavaScript fallback and earns its sentence in the progressive-enhancement lesson; omit it for now.

The next line is the one people get wrong, 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.

As last lesson put it, the function reference is the contract, and here it carries more weight. Both functions run your action and save the invoice, but only formAction reports back to the hook. Hand the form the raw one and you get nothing back: state stays null, isPending stays false, and you’re back to the broken form 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”

state is a discriminated union : { ok: true; data } on success, { ok: false; error } on failure, and null before the first submit. TypeScript won’t let you touch state.error until you’ve proven you’re on the failure branch, so gate every error read on state?.ok === false. Skip the gate and you get both a compile error and a runtime crash, since null.error and { ok: true }.error are both nonsense.

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

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

The form writes no error copy of its own. The action authored the userMessage for the business-rule failure (“That invoice number is already in use.”), and the form renders it verbatim: no rephrasing, no “Something went wrong.” fallback invented at the UI layer. A missing userMessage is a bug in the action that forgot to return one, not the form’s problem to paper over.

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 one place and the form’s JSX stays readable:

// 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 central point of this section. The action built fieldErrors with z.flattenError(parsed.error).fieldErrors precisely so the value would be flat: field name in, array of messages out. Reach for fieldErrors.email.errors or a nested object per field and you’ve reconstructed a shape the last chapter chose not to ship.

The strings themselves originate one layer up. The text “Enter a valid email address.” was authored once, in the Zod schema, when you declared the field’s validation; flattenError projects it into the flat map, and the form reads the map and renders. One set of strings, three layers: the schema authors them, the action projects them, the form renders them.

One accessibility detail the project inherits: a field with a possible error should tell assistive technology 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, minted with React’s useId() so the IDs stay stable across server render and client hydration. You’ll see it on the email field in the assembled form below; the earlier accessibility lesson covers the full rationale.

Symptom 3 from the opening, stale errors lingering after a fix, needs no code at all. The next submit replaces state with a fresh Result; if it succeeds, state.ok is true, and every block gated on state?.ok === false stops rendering. The old field error and banner vanish because the condition that drew them is gone.

Pending state: disabling the button to stop double-submits

Section titled “Pending state: disabling the button to stop double-submits”

isPending is 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 one disabled={isPending} is also your double-submit fix, and you get it for free. A disabled button doesn’t fire its click, so the impatient second click, the one that created a second invoice, lands on a button already disabled while the first submit is in flight, and React swallows it. The label swap to “Saving…” tells the user why the button went quiet: the screen finally moves when they click.

To freeze every input and not just the button, wrap the controls in <fieldset disabled={isPending}>, which disables every control inside it at once. It’s optional, since disabling the submit button is the minimum that stops the double-submit; reach for the fieldset when half-edited inputs mid-flight would confuse the user.

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

Drag through the diagram to see exactly when each of the three returns changes across one submit.

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.

Note Step 4: on failure React leaves the inputs alone, so the typed values stay put and the user fixes only the rejected field instead of retyping the other five. Keeping the values after a successful save is the edit-form case; state.data is the lever, and the project chapter writes it in full.

Assemble the pieces into one component, 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. It is the wire connecting 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, renders 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…’. That disabled state is also the double-submit guard.

1 / 1

This component is the deliverable of the lesson: every form in the rest of the course is this shape. The project chapter extends it with more fields, a reusable submit button next lesson, and optimistic updates the lesson after, but the skeleton never changes. Learn this one shape and you’ve learned the chapter.

To fix the lifecycle in memory, order the steps below as the form experiences a submit, top to bottom. The canonical form sits 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 is load-bearing, and wiring it once by hand sticks better than reading it. This form picks up mid-story: the user already submitted a bad email and the server rejected it, so the form re-renders with that prior failure as its initial state. Everything you need is already in state on the first paint, so a correct wiring shows the error without a submit. 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

    Stuck? The four tasks map onto the canonical form: the hook call (seeded with previousResult instead of null), the action={formAction} swap, the disabled={isPending} button, and the guarded fieldErrors?.email?.[0] read.

    The hook is the same everywhere; these four show it inside the full stack, with Server Actions, Zod validation, and progressive enhancement.