Actions and form hooks

React · Advanced · 18 min

Actions are React 19's built-in answer to pending states, errors, optimistic updates and form resets that every app hand-rolled before.

An Action is an async function run inside a transition. React tracks its pending state, and updates made by it are batched and applied when it finishes. You get Actions in several ways.

Form actions. Pass a function to <form action={fn}> (or <button formAction={fn}>). React calls it with the form's FormData on submit, without a page reload, and resets uncontrolled fields when it succeeds.

`useActionState(action, initialState)` returns [state, formAction, isPending]. Your action receives (previousState, formData) and returns the next state, typically a result or error message:

const [error, submit, isPending] = useActionState(async (prev, formData) => {
  const res = await updateName(formData.get("name"));
  return res.error ?? null;
}, null);
<form action={submit}>...</form>

Submissions are queued in order, and the state is the last returned value.

`useFormStatus()` (from react-dom) lets a component inside a form read { pending, data, method, action } of the enclosing form's submission. It's perfect for a reusable <SubmitButton> that disables itself, with no prop drilling. It must be called in a child of the <form>, not the component that renders it.

`useOptimistic(state, updateFn)` shows a temporary value while an Action runs, like a sent message appearing immediately. When the Action finishes, the optimistic value is discarded and the real state is shown; if it failed, the UI simply reverts.

You can also start an Action from any handler with startTransition(async () => {...}). Validate input in the action, return errors as state rather than throwing for expected failures, and remember that with a framework the action can be a Server Function, so the same form works before JavaScript loads.

Example

import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";

async function saveTodo(text) {
  await new Promise(r => setTimeout(r, 800));
  if (text.length < 3) throw new Error("Too short");
  return { id: crypto.randomUUID(), text };
}

function SubmitButton() {
  const { pending } = useFormStatus();          // reads the parent <form>
  return <button disabled={pending}>{pending ? "Saving..." : "Add"}</button>;
}

export default function Todos() {
  const [state, formAction] = useActionState(async (prev, formData) => {
    const text = formData.get("text");
    addOptimistic(text);
    try {
      return { todos: [...prev.todos, await saveTodo(text)], error: null };
    } catch (e) {
      return { ...prev, error: e.message };
    }
  }, { todos: [], error: null });

  const [optimisticTodos, addOptimistic] = useOptimistic(
    state.todos,
    (todos, text) => [...todos, { id: "pending", text: text + " (saving)" }]
  );

  return (
    <form action={formAction}>
      <input name="text" />
      <SubmitButton />
      {state.error && <p role="alert">{state.error}</p>}
      <ul>{optimisticTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
    </form>
  );
}

Remember

  • <form action={fn}> calls fn(formData) in a transition and resets the form on success
  • useActionState gives [state, action, isPending]; the action gets (prevState, formData)
  • useFormStatus reads the parent form's pending state; useOptimistic shows temporary UI

Watch out for

  • Calling useFormStatus in the same component that renders the <form>; it only reports on a parent form.

Check yourself

  1. A form component calls useFormStatus() and renders <form action={save}>. Why is pending always false?
  2. What does the action passed to useActionState receive as its first argument?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/reference/react/useActionState