Skip to content
intermediate Phase 6 · Server Actions and Forms

Tracking Form State with useActionState

Expose pending, success, and error states during submission.

40m
0 problems
Topic Progress 0%

Anatomy of the Tuple: What state, formAction, and isPending Hold Over Time

The hook is small enough to feel like plumbing, and most bugs come from guessing what its three slots contain at each moment instead of knowing. Here is the whole lifecycle for one submission.

'use client';
import { useActionState } from 'react';
import { subscribe } from './actions';

type State = { message?: string; errors?: Record<string, string> } | null;

export function SubscribeForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    subscribe,   // (prevState, formData) => Promise<State>
    null,        // initial state — visible before any submission
  );
  ...
}
Moment state isPending
first render, nothing submitted the initial value (null) false
submit clicked, action in flight previous value (unchanged) true
action returned a value that returned object false
action threw unchanged (error goes to the boundary) false
second submit in flight last returned value, still true

Three consequences fall straight out of that table.

The action's signature is fixed by the hook. Its first parameter is always the previous state (the initial value on the first call), the second is FormData when the form is bound through formAction, and it must return a serializable value — that returned value becomes state on the next render. Import the same function you would pass directly to <form action>; the hook wraps it, it does not require a different function.

Pending does not clear state. During a submission the old result stays rendered, so an error from attempt one remains visible while attempt two runs — arguably correct (it is still the last known outcome) but worth knowing when you consider an error "stale" while the user retries. If you want the error gone the instant they retry, clear it where you control the render: hide it while isPending is true.

state is component-scoped, not global. Two forms using the same action each hold their own tuple, and unmounting the component destroys the state. Conversely, remounting — a route change, or a key change — resets it to the initial value, which is the built-in way to return to a clean slate after a success.

The sibling API for the same problem is useTransition, and the difference is one of scope: a transition gives you isPending and keeps the UI responsive around an action you call yourself, while useActionState additionally owns the value the action returned and wires it into a form. The practical reading: use a transition when you are not rendering a result (a menu button that archives an order), use useActionState when the form must display what came back (validation errors, a confirmation). Both can be active at once — a transition wrapping an action that a form also submits is unusual, so pick one pending source per interaction to avoid two flags disagreeing.

One React-19 detail people miss: the hook's pending flag follows the action's promise, including any revalidatePath re-render it triggers — so isPending stays true until the UI has actually been updated with the fresh data, not merely until the database write returned. That is why the button label "Saving…" does not flicker off before the list moves. Everything here builds on the form wiring from Progressive Forms with Actions; what the returned state should contain — schemas, field errors, the double-validation discipline — is Validating Action Input.

The Pending Window: Disabling, Debouncing, and useFormStatus in a Child Button

Between the click and the response, the user can do damage: click again, hit Enter twice, navigate away, or mash the button on a slow connection where "again" means five times. The pending flag exists to close that window, and doing it properly means covering every control that can submit.

'use client';
import { useActionState } from 'react';
import { createOrder } from './actions';

export function OrderForm() {
  const [state, formAction, isPending] = useActionState(createOrder, null);

  return (
    <form action={formAction} aria-busy={isPending}>
      <input name="sku" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Placing order…' : 'Place order'}
      </button>
      {state?.message && <p role="status">{state.message}</p>}
    </form>
  );
}

Disabling the submit button is the headline rule, and it is worth stating its limits plainly: it prevents double clicks after hydration, nothing more. A native submit before React hydrates, a retry injected by the network layer, or a scripted POST all reach the action anyway. So the flag is a UX guard and the action still needs idempotency — a unique constraint or a client-generated order id — as the actual defense. Interviewers who hear only "I disable the button" follow up for exactly this reason.

Now the structural problem: the button is often a child component. A shared <SubmitButton label="Place order" /> cannot read the parent's isPending without prop-drilling, and prop-drilling a pending flag through a form's layout is the kind of coupling that rots. React's answer reads the pending state from context: useFormStatus reports on the nearest parent <form>.

'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();   // status of the enclosing form
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Working…' : label}
    </button>
  );
}

The rule that confuses everyone: useFormStatus must be called in a component inside the form, not the component that renders the form itself — the provider is the <form> element, so the form component is outside its own context and gets pending: false. If your button never seems to disable, that is the bug.

Layer the remaining defenses so nothing slips through:

Guard Catches Does not catch
disabled={isPending} / useFormStatus repeated clicks after hydration pre-hydration native submit
aria-busy + label swap user confusion (screen readers, mid-flight) duplicate network requests
submit-once guard in the action (WHERE id IS NULL) racing duplicates —
unique constraint / client id everything, at the data layer —

One accessibility note folded into the same code: aria-busy="true" on the form and role="status" on the result message let assistive tech announce the state change that sighted users get from the label swap. And keep the layout stable — a button whose label changes width jumps under the cursor, so give the pending and idle labels the same box (min-width, or a fixed-height spinner). None of this is cosmetic: the pending window is where forms feel either trustworthy or broken, and the flag is only the beginning of covering it.

Errors That Persist: Keeping Returned Errors Attached Until the Next Submit

The persistence rule is a deliberate design choice: a returned error stays in state until the next submission attempt. React is not going to clear it for you, because the user needs time to read it, fix the field, and resubmit — and if the message vanished the moment they started typing, the feedback loop would break.

That rule produces a specific bug when success and failure share a shape. Consider an action that returns errors on failure and a confirmation on success:

'use server';
export async function subscribe(prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email') ?? '');
  if (!email.includes('@')) return { errors: { email: 'Enter a valid email' } };
  await db.subscription.create({ data: { email } });
  return { message: `Subscribed ${email}` };
}

Now the rendering side must decide which fields to show, and the safe rule is: show errors only when no success is present, or better, make the state a discriminated union so TypeScript forces the branch:

type State =
  | { status: 'idle' }
  | { status: 'error'; errors: Record<string, string> }
  | { status: 'success'; message: string };

A union like that makes "error still on screen after a successful submit" a compile error rather than a QA finding, and it documents the lifecycle in the type: idle → error → (retry) → success. Since state only changes when the action returns, the transition to success is exactly the moment stale errors must disappear — the union guarantees you render one branch, not two.

What about failures that were thrown rather than returned? They never become state: the promise rejects, the nearest error.tsx renders, and your tuple keeps its previous value. So the persistence rule applies only to returned errors — thrown ones are handled by boundaries, and the form itself is replaced by the error UI (with its retry button) rather than showing an inline message. Keep the split from the earlier topics: returned = expected, inline, keeps input; thrown = unexpected, boundary, replaces the region.

Resetting has three legitimate tools, in order of preference:

  1. Return a clean state. After success, return { status: 'success' } — no leftover error keys. Nothing else needed.
  2. Remount with key. key={state.status === 'success' ? 'done' : 'form'} discards both the fields and the state — right after a submission succeeds and the form should stop being editable.
  3. Initial value + new submission. The next submit overwrites state when it returns; combine with hiding errors while isPending if you need them to clear earlier.

What to avoid: mutating the returned object (state.errors = {}) — the state belongs to React, and you will be fighting re-renders; and deriving truth from it later (if (state.errors) block navigation) — state is presentation feedback, not a source of truth. The server already knows whether the record exists; if you need that fact for flow control, ask the server, do not infer it from the last form message. That boundary — returned state drives the UI, data drives decisions — is the durable lesson of the hook.

Quiz

1. During the second submission of a form whose first attempt returned an error, what does useActionState's state slot contain?

Question 1 options

2. A shared <SubmitButton /> inside a form never reports pending: true from useFormStatus. Why?

Question 2 options

3. Why is disabling the submit button with isPending not a complete answer to double submission?

Question 3 options

4. A form shows 'Enter a valid email' after the user's second, valid submission succeeded. Best fix?

Question 4 options

Flashcards

Question

What are the three slots of useActionState, and when does each change?

Answer

[state, formAction, isPending]. state = initial value until the action returns, then the returned value (pending does not clear it; a throw leaves it unchanged). formAction = bound action for <form action={...}>. isPending = true while the action's promise — including its revalidation — is unresolved.

Question

How does a nested button component read the form's pending state?

Answer

useFormStatus() from react-dom returns { pending, data, method, action } from the nearest enclosing <form> via context. It must be called in a component *inside* the form — the component that renders the form sits outside its own context and always sees pending: false.

Question

What does "errors stay attached until the next submission" mean in practice?

Answer

state holds the last returned value until a new return arrives, so a validation error remains rendered while the user edits and during the retry — it disappears only when the action returns again (success or new errors). Hide it earlier by gating the render on isPending if the UX needs it.

Question

Returned errors vs thrown errors in an action — different lifecycles?

Answer

Returned: become state, render inline, form and input preserved, persists until next submit. Thrown: reject the promise, never reach state, propagate to the nearest error.tsx which replaces the region (with retry). Expected/business failures → return; unexpected → throw.

Question

Three legitimate ways to reset form state after success?

Answer

1) return a clean state (no error keys — best, and a discriminated union enforces it); 2) remount with a key change derived from success (clears fields and state); 3) let the next submission overwrite it, optionally hiding errors while pending. Avoid mutating the hook's state or treating it as flow-control truth.

Revision Notes

Key Takeaways

  • 1. The tuple reports: last returned value, bound form action, and a pending flag that stays true through revalidation
  • 2. Pending never clears state — errors remain visible during a retry unless you gate the render yourself
  • 3. useFormStatus reads the enclosing form's status from context, so it belongs in a child component, not the form's own
  • 4. Disabling the button is UX coverage only; idempotency in the action is what stops duplicates
  • 5. A discriminated union for state turns 'stale error after success' into a compile error and documents the lifecycle

Interview Tips

  • • "Walk me through useActionState" — name the three slots and their timeline: state holds the last return (pending doesn't clear it), isPending covers action + revalidation, formAction is the bound wrapper
  • • "How do you prevent double submits?" — layered answer: pending-disabled button (post-hydration), aria-busy for the user, idempotent action for correctness; explain why the button alone fails
  • • "useFormStatus in my button always returns false" — it reads the nearest parent form; the component rendering the form is outside its own context
  • • "How do you clear errors?" — return a clean success state (union type enforces one branch) or remount with key; reject mutation and setTimeout approaches

Cheat Sheet

Tracking Form State with useActionState

const [state, formAction, isPending] = useActionState(action, initialState);
// action: (prevState, formData) => Promise<State>
<form action={formAction} aria-busy={isPending}>
  <SubmitButton />   {/* child: const { pending } = useFormStatus() */}
</form>

Lifecycle:

Moment state isPending
first render initial value false
in flight previous value (unchanged) true
returned that value false
threw unchanged (error.tsx instead) false

State shape (discriminated union):

type State =
  | { status: 'idle' }
  | { status: 'error'; errors: Record<string, string> }
  | { status: 'success'; message: string };

Renders exactly one branch → stale errors after success become a type error.

Pending guards: disabled={isPending} on button + inputs, aria-busy on form, stable-width label. Coverage stops at hydration → action still needs idempotency (unique constraint / client-generated id).

Reset: return clean state (preferred) · remount via key on success · next submit overwrites. Never mutate the hook's state; state is presentation, not source of truth.

Continue Learning