Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

useActionState and form Actions in React

Turtlekit Editorial Team

The bookkeeper recorded Northwind’s bank transfer against INV-2041 just as the office Wi-Fi dropped. Ten minutes later the button still read “Recording…”. She reloaded the page, typed the payment in again, and spent the rest of the afternoon wondering whether Northwind had now paid twice.

Northwind hadn’t. The first request never reached the server, and the form never found out. Its handler set a flag before the request and cleared it after, and the request threw in between, so the line that cleared the flag never ran.

React 19 lets a form’s action be a function, and useActionState wraps that function so each submission returns the form’s next state: the errors, what was typed, the confirmation. React keeps the form pending for exactly as long as the Action runs, so there is no flag left to forget.

A form that keeps its own books

This was the submit handler, and it reads as careful code:

// Avoid: a dropped connection throws past setSaving(false)
async function submit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const form = new FormData(event.currentTarget);
  setSaving(true);
  setError(null);
  const result = await recordPayment(invoice.id, {
    amount: toPence(String(form.get("amount"))),
    reference: String(form.get("reference")),
  });
  if (!result.ok)
    setError(result.errors.amount ?? "Check the payment.");
  setSaving(false);
}

Count what it asks of the component. It stops the browser’s own submission, reads the fields, raises one flag and clears another, and then has to come back after the await to undo both. When the request rejects, nothing comes back. The error escapes the handler, the second setSaving never runs, and the button stays disabled until the page is reloaded.

A try and finally fixes this form. The next form needs the same ceremony, written again, and the bookkeeping grows with every field the server can refuse: an error per field, cleared by hand on the next attempt.

Each submission returns the form’s next state

Think of the form as a reducer that may wait. A submission takes the last answer and what was typed, talks to the server, and returns the next answer. useActionState holds that answer, hands it to the next submission, and reports whether one is running.

the next submitpreviouslast answerformFormDatarecord()the requeststatenext answerpending while it runs
Each submission is one turn of the loop. record() gets the last answer and what was typed, and returns the next answer. The next submission starts from it, and waits for it.

Return the form’s next state, and let React hold the waiting. Refusals and dropped connections become values the form renders, like any other answer, and the pending state belongs to the Action itself, so it can’t outlive it.

Building the payment form around its answer

The finished form is one file, record-payment.tsx. It starts with the shape of an answer, then the Action that produces it, then the parts that show it.

The answer a submission returns

interface PaymentState {
  /** New for every answer, so a repeated message is a new line. */
  id: number;
  /** The confirmation, or why nothing was recorded. */
  message: string | null;
  errors: Partial<Record<keyof PaymentValues, string>>;
  /** What was sent, kept in the fields after a refusal. */
  values: PaymentValues | null;
}

const START: PaymentState = {
  id: 0,
  message: null,
  errors: {},
  values: null,
};

type Field = keyof PaymentValues;
const LABELS: Record<Field, string> = {
  amount: "amount",
  reference: "bank reference",
};

Every field is something the form renders. values is the one that looks optional and isn’t; the pitfall below says why. id grows by one with every answer, so the status line can show a repeated message as a new line.

An Action that answers instead of throwing

/** The form's Action and state; each call gets the last answer. */
export function usePaymentForm(
  invoice: Invoice,
  recordPayment: RecordPayment,
) {
  async function record(
    previous: PaymentState,
    form: FormData,
  ): Promise<PaymentState> {
    const values = {
      amount: String(form.get("amount")),
      reference: String(form.get("reference")),
    };
    const id = previous.id + 1;
    try {
      const amount = toPence(values.amount);
      const result = await recordPayment(invoice.id, {
        amount,
        reference: values.reference,
      });
      if (!result.ok) {
        const fix = Object.keys(result.errors).map(
          (f) => LABELS[f as Field],
        );
        const fields = fix.join(" and ");
        const message = `Nothing was recorded: check the ${fields}.`;
        return { id, message, errors: result.errors, values };
      }
      const left =
        result.balance === 0
          ? "The invoice is paid."
          : `${money(result.balance)} left.`;
      return {
        id,
        message: `${money(amount)} recorded. ${left}`,
        errors: {},
        values: null,
      };
    } catch {
      const message =
        "Nothing was recorded: the connection dropped. Try again.";
      return { id, message, errors: {}, values };
    }
  }
  return useActionState(record, START);
}

record receives the previous answer first and the submitted FormData second, as the useActionState reference describes. A refusal from the server and a dropped connection both come back as answers, so the form shows them beside the fields. What would still throw is a bug, and React sends it to the nearest error boundary and cancels the submissions queued behind it.

Pitfall

React resets a form’s uncontrolled fields when its Action completes without throwing, and a returned refusal counts as completing. Without values, the bookkeeper would read “That’s more than the £1,200.00 left to pay” above an empty Amount field. Return what was submitted and feed it back as defaultValue, so the reset brings the typing back, as react.dev’s reference for form recommends. After a recorded payment, values is null and the fields clear.

A button that knows its form is busy

function RecordButton() {
  // The status of the form this button is rendered in.
  const { pending } = useFormStatus();
  function refuseWhilePending(event: MouseEvent<HTMLButtonElement>) {
    if (pending) event.preventDefault();
  }
  return (
    <button
      type="submit"
      aria-disabled={pending || undefined}
      onClick={refuseWhilePending}
    >
      {pending ? "Recording…" : "Record payment"}
    </button>
  );
}

useFormStatus reads the status of the form a component is rendered in, so a submit button in a shared library can show the pending state of any form without a prop. The button stays focusable with aria-disabled and refuses the click itself. Pressing Enter in a field submits through a click on that same button, so one guard covers both.

Focus on what to fix

const amountRef = useRef<HTMLInputElement>(null);
const referenceRef = useRef<HTMLInputElement>(null);
// The first refused field takes focus, so its message is read with
// it.
useEffect(() => {
  if (state.errors.amount) amountRef.current?.focus();
  else if (state.errors.reference) referenceRef.current?.focus();
}, [state]);

The first refused field takes focus, and a screen reader reads its label, its value and the error in one go. After Enter, focus is already there and moving it says nothing, so the Action also returns a line for the status region: “Nothing was recorded: check the amount.”

The finished form

"use client";
// The invoice page's payment form: its Action returns the next state,
// and React keeps the form pending while the Action runs.
import { useActionState, useEffect, useRef } from "react";
import type { MouseEvent } from "react";
import { useFormStatus } from "react-dom";
import { money, toPence } from "./payments";
import type {
  Invoice,
  PaymentValues,
  RecordPayment,
} from "./payments";

interface PaymentState {
  /** New for every answer, so a repeated message is a new line. */
  id: number;
  /** The confirmation, or why nothing was recorded. */
  message: string | null;
  errors: Partial<Record<keyof PaymentValues, string>>;
  /** What was sent, kept in the fields after a refusal. */
  values: PaymentValues | null;
}

const START: PaymentState = {
  id: 0,
  message: null,
  errors: {},
  values: null,
};

type Field = keyof PaymentValues;
const LABELS: Record<Field, string> = {
  amount: "amount",
  reference: "bank reference",
};

function RecordButton() {
  // The status of the form this button is rendered in.
  const { pending } = useFormStatus();
  function refuseWhilePending(event: MouseEvent<HTMLButtonElement>) {
    if (pending) event.preventDefault();
  }
  return (
    <button
      type="submit"
      aria-disabled={pending || undefined}
      onClick={refuseWhilePending}
    >
      {pending ? "Recording…" : "Record payment"}
    </button>
  );
}

/** The form's Action and state; each call gets the last answer. */
export function usePaymentForm(
  invoice: Invoice,
  recordPayment: RecordPayment,
) {
  async function record(
    previous: PaymentState,
    form: FormData,
  ): Promise<PaymentState> {
    const values = {
      amount: String(form.get("amount")),
      reference: String(form.get("reference")),
    };
    const id = previous.id + 1;
    try {
      const amount = toPence(values.amount);
      const result = await recordPayment(invoice.id, {
        amount,
        reference: values.reference,
      });
      if (!result.ok) {
        const fix = Object.keys(result.errors).map(
          (f) => LABELS[f as Field],
        );
        const fields = fix.join(" and ");
        const message = `Nothing was recorded: check the ${fields}.`;
        return { id, message, errors: result.errors, values };
      }
      const left =
        result.balance === 0
          ? "The invoice is paid."
          : `${money(result.balance)} left.`;
      return {
        id,
        message: `${money(amount)} recorded. ${left}`,
        errors: {},
        values: null,
      };
    } catch {
      const message =
        "Nothing was recorded: the connection dropped. Try again.";
      return { id, message, errors: {}, values };
    }
  }
  return useActionState(record, START);
}

interface RecordPaymentFormProps {
  invoice: Invoice;
  recordPayment: RecordPayment;
}

export function RecordPaymentForm({
  invoice,
  recordPayment,
}: RecordPaymentFormProps) {
  const [state, formAction] = usePaymentForm(invoice, recordPayment);

  const amountRef = useRef<HTMLInputElement>(null);
  const referenceRef = useRef<HTMLInputElement>(null);
  // The first refused field takes focus, so its message is read with
  // it.
  useEffect(() => {
    if (state.errors.amount) amountRef.current?.focus();
    else if (state.errors.reference) referenceRef.current?.focus();
  }, [state]);

  const { errors, values } = state;
  return (
    <form action={formAction} aria-labelledby="payment-heading">
      <h2 id="payment-heading">
        Record a payment for {invoice.number}
      </h2>
      <p>Left to pay: {money(invoice.balance)}</p>
      <label htmlFor="amount">Amount</label>
      <input
        ref={amountRef}
        id="amount"
        name="amount"
        inputMode="decimal"
        required
        // React resets the form after the Action; this brings a
        // refused amount back instead of an empty field.
        defaultValue={values?.amount ?? ""}
        aria-invalid={errors.amount ? true : undefined}
        aria-describedby={errors.amount ? "amount-error" : undefined}
      />
      {errors.amount ? (
        <p id="amount-error">{errors.amount}</p>
      ) : null}
      <label htmlFor="reference">Bank reference</label>
      <input
        ref={referenceRef}
        id="reference"
        name="reference"
        defaultValue={values?.reference ?? ""}
        aria-invalid={errors.reference ? true : undefined}
        aria-describedby={
          errors.reference ? "reference-error" : undefined
        }
      />
      {errors.reference ? (
        <p id="reference-error">{errors.reference}</p>
      ) : null}
      <RecordButton />
      <p role="status">
        {state.message ? (
          <span key={state.id}>{state.message}</span>
        ) : null}
      </p>
    </form>
  );
}

Type “1,250.00” and press Record payment. The button reads “Recording…” and ignores more presses. When the server refuses, the amount is still there, marked invalid, with the reason under it and focus on it, and the status line names the field to check. Record £600.00 instead and the fields clear while the status line says what was recorded and what is left. Drop the connection and the button comes back with a sentence that says nothing was recorded.

What the queue decides for you

useActionState makes one strong choice on your behalf, and most of its behaviour follows from it.

One submission at a time, in order

React queues the calls and runs them one after another, each receiving the result of the one before. For a payment that is the right default. Two answers can never arrive out of order and overwrite each other, and an answer always describes the latest submission. The cost is waiting: a second submission doesn’t start until the first has its answer. It is also why the button refuses presses while pending. The queue would otherwise record a second payment the moment the first finished.

When submissions should run side by side, such as invites to several teammates, use useTransition and keep each result yourself, as the optimistic invite list does.

The answer, in words

The button stays focusable and says “Recording…” while it works. The refused field carries aria-invalid and points at its message with aria-describedby. The status line is in the page, empty, before the first answer. Each answer is a new keyed line, so “Nothing was recorded” is heard again on a second failure instead of being skipped as unchanged.

Testing through the form

The test hands the form a server it answers by hand, so it can look at the page while a payment is out:

import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import type { Invoice, PaymentResult } from "./payments";
import { RecordPaymentForm } from "./record-payment";

const INVOICE: Invoice = {
  id: "inv_2041",
  number: "INV-2041",
  customer: "Northwind Ltd",
  balance: 120000,
};

/** A server the test answers by hand, one payment at a time. */
function heldServer() {
  const answers: {
    resolve: (r: PaymentResult) => void;
    reject: () => void;
  }[] = [];
  const recordPayment = vi.fn(
    () =>
      new Promise<PaymentResult>((resolve, reject) => {
        answers.push({
          resolve,
          reject: () => reject(new Error("Network")),
        });
      }),
  );
  return { recordPayment, answers };
}

function renderForm() {
  const server = heldServer();
  render(
    <RecordPaymentForm
      invoice={INVOICE}
      recordPayment={server.recordPayment}
    />,
  );
  return server;
}

const amount = () => screen.getByRole("textbox", { name: "Amount" });

test("a refusal keeps the amount and says why", async () => {
  const user = userEvent.setup();
  const server = renderForm();
  await user.type(amount(), "1,250.00");
  await user.click(
    screen.getByRole("button", { name: "Record payment" }),
  );
  const pending = screen.getByRole("button", { name: "Recording…" });
  expect(pending).toHaveAttribute("aria-disabled", "true");
  await user.click(pending);
  expect(server.recordPayment).toHaveBeenCalledTimes(1);
  const why = "That’s more than the £1,200.00 left to pay.";
  await act(async () =>
    server.answers[0]?.resolve({
      ok: false,
      errors: { amount: why },
    }),
  );
  expect(amount()).toHaveValue("1,250.00");
  expect(amount()).toHaveAttribute("aria-invalid", "true");
  expect(amount()).toHaveAccessibleDescription(why);
  expect(amount()).toHaveFocus();
});

test("Enter submits; a refusal is said where focus is", async () => {
  const user = userEvent.setup();
  const server = renderForm();
  await user.type(amount(), "1,250.00{Enter}");
  expect(server.recordPayment).toHaveBeenCalledTimes(1);
  const why = "That’s more than the £1,200.00 left to pay.";
  await act(async () =>
    server.answers[0]?.resolve({
      ok: false,
      errors: { amount: why },
    }),
  );
  expect(amount()).toHaveFocus();
  const status = screen.getByRole("status");
  expect(status).toHaveTextContent(
    "Nothing was recorded: check the amount.",
  );
});

test("a refused reference is marked and takes focus", async () => {
  const user = userEvent.setup();
  const server = renderForm();
  await user.type(amount(), "600");
  await user.click(
    screen.getByRole("button", { name: "Record payment" }),
  );
  const why = "This reference is already on payment PAY-0912.";
  await act(async () =>
    server.answers[0]?.resolve({
      ok: false,
      errors: { reference: why },
    }),
  );
  const reference = screen.getByRole("textbox", {
    name: "Bank reference",
  });
  expect(reference).toHaveAttribute("aria-invalid", "true");
  expect(reference).toHaveAccessibleDescription(why);
  expect(reference).toHaveFocus();
  expect(screen.getByRole("status")).toHaveTextContent(
    "check the bank reference",
  );
});

test("a recorded payment clears the form and says so", async () => {
  const user = userEvent.setup();
  const server = renderForm();
  const status = screen.getByRole("status");
  expect(status).toBeEmptyDOMElement();
  await user.type(amount(), "600");
  await user.click(
    screen.getByRole("button", { name: "Record payment" }),
  );
  await act(async () =>
    server.answers[0]?.resolve({ ok: true, balance: 60000 }),
  );
  expect(status).toHaveTextContent("£600.00 recorded. £600.00 left.");
  expect(amount()).toHaveValue("");
});

test("a drop ends pending; each retry is a new line", async () => {
  const user = userEvent.setup();
  const server = renderForm();
  await user.type(amount(), "1,200.00");
  const button = screen.getByRole("button", {
    name: "Record payment",
  });
  await user.click(button);
  await act(async () => server.answers[0]?.reject());
  expect(button).toHaveAccessibleName("Record payment");
  expect(button).not.toHaveAttribute("aria-disabled");
  expect(amount()).toHaveValue("1,200.00");
  const status = screen.getByRole("status");
  const first = within(status).getByText(/Nothing was recorded/);
  await user.click(button);
  await act(async () => server.answers[1]?.reject());
  expect(within(status).getByText(/Nothing was recorded/)).not.toBe(
    first,
  );
});

All five pass. The last replays the opening: the request rejects, and the button returns to Record payment with the amount still typed in. The second presses Enter in the field and finds the refusal in the status line.

Server Functions and the form before hydration

recordPayment can be a Server Function with no change to the form. To let the form submit before its JavaScript has loaded, the Action given to useActionState has to be the Server Function itself. Its third argument, permalink, names the page the browser goes to on that early submit. Here the Action is client code wrapped around the request, so this form waits for hydration. That is the trade: the wrapper keeps the field values and the friendly messages in the browser; a Server Function Action has to return them from the server.

React 18, 19 and the Compiler

Function Actions on <form>, useActionState and useFormStatus arrived with React 19; Canary releases called the hook useFormState. On React 18, the first version with a try and finally is the way to write this form. The React Compiler leaves it alone: the work here is in state and Actions, not in memoised values.

When a form doesn’t need a running answer

Reach for useActionState when each submission produces an answer the form shows. A form that only navigates on success, such as a sign-out, needs a function action and useFormStatus for the button, and no state at all.

Checks that run while people type, such as a VAT number’s format, stay with the field; an Action answers on submit. And when the work isn’t a submission, or several may run at once, a Transition with state of your own fits better than a queue.

How Turtlekit’s TextField, ErrorSummary and Button carry the answer

With Turtlekit, the same hook drives the kit’s form parts. TextField takes the error as a prop and wires aria-invalid and the description itself. ErrorSummary takes focus when a refusal comes back and says the errors again after each failed submit, which replaces the hand-written focus effect. Button takes the form’s pending state as its status:

"use client";
// The payment form with Turtlekit: TextField wires its label and
// error, ErrorSummary takes focus after a refusal, and Button shows
// the pending state of the form it submits.
import { useFormStatus } from "react-dom";
import {
  Button,
  ErrorSummary,
  Stack,
  Text,
  TextField,
} from "turtlekit";
import { money } from "./payments";
import type { Invoice, RecordPayment } from "./payments";
import { usePaymentForm } from "./record-payment";

function RecordButton() {
  const { pending } = useFormStatus();
  return (
    <Button
      type="submit"
      status={pending ? "pending" : "idle"}
      pendingLabel="Recording…"
    >
      Record payment
    </Button>
  );
}

interface PaymentFormProps {
  invoice: Invoice;
  recordPayment: RecordPayment;
}

export function PaymentForm({
  invoice,
  recordPayment,
}: PaymentFormProps) {
  const [state, formAction] = usePaymentForm(invoice, recordPayment);
  const { errors, values } = state;
  return (
    <form action={formAction} noValidate>
      <Stack gap="stack" align="start">
        <ErrorSummary
          errors={(["amount", "reference"] as const).flatMap((id) => {
            const message = errors[id];
            return message ? [{ id, message }] : [];
          })}
        />
        <TextField
          id="amount"
          name="amount"
          label="Amount"
          hint={`Left to pay: ${money(invoice.balance)}`}
          money
          required
          defaultValue={values?.amount ?? ""}
          error={errors.amount}
        />
        <TextField
          id="reference"
          name="reference"
          label="Bank reference"
          defaultValue={values?.reference ?? ""}
          error={errors.reference}
        />
        <RecordButton />
        <Text as="p" role="status">
          {state.message ? (
            <span key={state.id}>{state.message}</span>
          ) : null}
        </Text>
      </Stack>
    </form>
  );
}

The Action and its answer are unchanged. While the status is pending, the button shows a spinner beside “Recording…”, ignores presses and keeps focus. A refused amount comes back in its field, because defaultValue still reads the returned values.

When the form loses its answer

The fields empty after a refusal

React reset the form because the Action completed. Return the submitted values with the errors and pass them to each field’s defaultValue.

useFormStatus never reports pending

It is called in the component that renders the <form>. It reads the form a component is rendered inside, so move it into a child of the form, such as the submit button.

“An async function with useActionState was called outside of a transition”

The dispatch function was called from an ordinary event handler. Pass it to a form’s action or a button’s formAction, or call it inside startTransition, so that isPending updates.

Pending for as long as the Action runs

The first form told its story through flags it raised and lowered by hand, and one exception was enough to leave a flag up all afternoon. The finished form keeps no flags. Every submission ends in an answer the form can render: a confirmation, a refusal with the typing still in its fields, or a plain sentence that nothing was recorded. The waiting belongs to the Action, so it ends when the Action does, and the bookkeeper can see where her payment stands.

useTransition and Actions in React

When the work isn’t a form, or submissions may overlap and need no earlier result.

Optimistic UI updates with useOptimistic

When the result should show at the click, before the server answers.

One status instead of many booleans

When the state a submission returns grows flags that can disagree.

Compound components in React

When the form’s fields share their wiring: the label, the hint and the error.