Emmanuel Akinfulubi

React 19 all 2 written

Actions and useActionState, optimistic updates and useFormStatus: forms that talk to the server.

28 Actions and useActionState

Until now, a form in React meant an onSubmit handler that calls event.preventDefault(), reads the fields and manages "is it sending?" with state. React 19 moves that into the framework: give the form a function as its action, and React reads the fields for you, runs the function, and tells you while it is still running.

A function as the form's action

useActionState(action, initialState) returns three things: the current state, a formAction to give to the form, and isPending. The action receives the previous state and the form's FormData, and what it returns becomes the new state. It may be async: the form stays "pending" until the returned promise settles. The panel holds that promise open so it can read the page in the middle.

useActionState, held open

ran at build time · in happy-dom 20.11.6 · React dev

action.jsx

import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';

let calls = 0;
let release;

async function save(previous, formData) {
  calls += 1;
  const title = formData.get('title');
  await new Promise((resolve) => {
    release = resolve;
  });
  return { saved: title };
}

function NewBook() {
  const [state, formAction, isPending] = useActionState(save, { saved: null });
  return (
    <form action={formAction}>
      <input name="title" defaultValue="Dune" />
      <button>{isPending ? 'saving' : 'save'}</button>
      <p>{state.saved ? 'saved ' + state.saved : 'nothing yet'}</p>
    </form>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<NewBook />));
const button = () => stage.querySelector('button').textContent;
const message = () => stage.querySelector('p').textContent;
const form = stage.querySelector('form');
const steps = [[button(), message()]];

await act(async () => form.requestSubmit());
steps.push([button(), message()]);
await act(async () => release());
steps.push([button(), message()]);
console.log(steps, calls);

check('before: ready and empty', steps[0], ['save', 'nothing yet']);
check('mid-flight: pending, nothing saved yet', steps[1], ['saving', 'nothing yet']);
check('after: the action returned the title it read from the form', steps[2], ['save', 'saved Dune']);
check('the action ran once', calls, 1);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node action.jsx

[
  [ 'save', 'nothing yet' ],
  [ 'saving', 'nothing yet' ],
  [ 'save', 'saved Dune' ]
] 1
ok   before: ready and empty
ok   mid-flight: pending, nothing saved yet
ok   after: the action returned the title it read from the form
ok   the action ran once

Return an error, do not throw it

An action can fail in two ways. A thing the user got wrong, an empty title, is not an exception, it is a normal result, so return it as state and show it. The action below validates and returns { error }; the form simply renders it. (An action that throws is handled like any render error: lesson 27's boundary.)

An action that returns an error

ran at build time · in happy-dom 20.11.6 · React dev

validate.jsx

import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';

let calls = 0;

async function save(previous, formData) {
  calls += 1;
  const title = String(formData.get('title')).trim();
  if (!title) return { error: 'Title is required', saved: null };
  return { error: '', saved: title };
}

function NewBook({ initial }) {
  const [state, formAction] = useActionState(save, { error: '', saved: null });
  return (
    <form action={formAction}>
      <input name="title" defaultValue={initial} />
      <button>save</button>
      <p role="status">{state.error || (state.saved ? 'saved ' + state.saved : '')}</p>
    </form>
  );
}

async function submit(initial) {
  const root = createRoot(stage);
  await act(async () => root.render(<NewBook initial={initial} />));
  await act(async () => stage.querySelector('form').requestSubmit());
  const text = stage.querySelector('p').textContent;
  await act(async () => root.unmount());
  return text;
}

const empty = await submit('');
const filled = await submit('Emma');
console.log(empty, '|', filled, calls);

check('an empty title came back as an error message', empty, 'Title is required');
check('a title came back as saved', filled, 'saved Emma');
check('the action ran once per submit', calls, 2);

What node printed (React 19.2.7, development build), when the build ran it

node validate.jsx

Title is required | saved Emma 2
ok   an empty title came back as an error message
ok   a title came back as saved
ok   the action ran once per submit

The attribute React writes

Browsers know how to submit a form to a URL, not to a function. So that nothing runs the wrong way if the form is ever submitted natively, for example before the page's JavaScript has loaded, React gives the action attribute a javascript: URL that throws an error with an explanation. Once React is running it intercepts the submit and calls your function. This is the one place in the course where a form's own markup is printed; the panel cuts it to its first 60 characters.

The action attribute, cut short

ran at build time · in happy-dom 20.11.6 · React dev

attribute.jsx

import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Plain() {
  const [state, formAction] = useActionState(async () => 'done', 'start');
  return (
    <form action={formAction}>
      <button>{state}</button>
    </form>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Plain />));
const attribute = stage.querySelector('form').getAttribute('action');
console.log(attribute.slice(0, 60));

check('it is a javascript: URL that throws', attribute.startsWith('javascript:throw new Error('), true);
check('the first 60 characters are all we print', attribute.slice(0, 60).length, 60);
check('it is in the markup React wrote', stage.innerHTML.includes('action="javascript:'), true);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node attribute.jsx

javascript:throw new Error('A React form was unexpectedly su
ok   it is a javascript: URL that throws
ok   the first 60 characters are all we print
ok   it is in the markup React wrote

Promises, which every async action returns, are covered in the Node.js course: promises and async/await.

  • <form action={fn}> calls fn(formData) when the form is submitted.
  • useActionState(action, initial) gives [state, formAction, isPending].
  • The action gets the previous state and the FormData; its return value is the next state.
  • Return expected failures as state. Throw only for the unexpected.
  • React writes a javascript: URL into action as a guard against a native submit.

29 useOptimistic and useFormStatus

A real save takes time, and an interface that waits for it feels slow. The trick is to show the result at once and be ready to take it back. React 19 has a pair of tools for it: useOptimistic for the list you show, and useFormStatus for the button that reflects whether the form is busy.

Three moments of one save

useOptimistic(state, update) returns a version of state and a function, here addOptimistic, that adds a temporary change. The action calls it first, so the row appears at once, marked (saving), and then it waits for the pretend server. When the action finishes, the temporary change is dropped and the real state, which now holds the new book, takes its place. useFormStatus must be called from a component inside the form, which is why the button is its own component. The panel reads the list and the button three times and prints each as one line.

useOptimistic and useFormStatus, three moments

ran at build time · in happy-dom 20.11.6 · React dev

optimistic.jsx

import { useActionState, useOptimistic, act } from 'react';
import { useFormStatus } from 'react-dom';
import { createRoot } from 'react-dom/client';

let release;

function Status() {
  const { pending } = useFormStatus();
  return <button>{pending ? 'saving' : 'add'}</button>;
}

function Books() {
  const [books, formAction] = useActionState(async (previous, formData) => {
    const title = formData.get('title');
    addOptimistic(title);
    await new Promise((resolve) => {
      release = resolve;
    });
    return [...previous, title];
  }, ['Dune']);
  const [shown, addOptimistic] = useOptimistic(books, (current, title) => [...current, title + ' (saving)']);
  return (
    <div>
      <form action={formAction}>
        <input name="title" defaultValue="Emma" />
        <Status />
      </form>
      <ul>
        {shown.map((title) => (
          <li key={title}>{title}</li>
        ))}
      </ul>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Books />));
const line = (n) => n + ' ' + stage.querySelector('ul').innerHTML + ' ' + stage.querySelector('button').textContent;
const lines = [line(1)];

await act(async () => stage.querySelector('form').requestSubmit());
lines.push(line(2));
await act(async () => release());
lines.push(line(3));
lines.forEach((text) => console.log(text));

check('1: before the save', lines[0], '1 <li>Dune</li> add');
check('2: the new row is there at once, and the button is busy', lines[1], '2 <li>Dune</li><li>Emma (saving)</li> saving');
check('3: the real list replaced the temporary one', lines[2], '3 <li>Dune</li><li>Emma</li> add');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node optimistic.jsx

1 <li>Dune</li> add
2 <li>Dune</li><li>Emma (saving)</li> saving
3 <li>Dune</li><li>Emma</li> add
ok   1: before the save
ok   2: the new row is there at once, and the button is busy
ok   3: the real list replaced the temporary one

When the save fails

The temporary row only lives while the action runs. When the action ends React throws it away and shows whatever the real state is. If the action failed and returned the old list, the row vanishes, which is exactly right: the book was not saved, and the page says so. The action below returns an error alongside the unchanged list.

The optimistic row disappears

ran at build time · in happy-dom 20.11.6 · React dev

rollback.jsx

import { useActionState, useOptimistic, act } from 'react';
import { createRoot } from 'react-dom/client';

let release;

function Books() {
  const [state, formAction] = useActionState(
    async (previous, formData) => {
      const title = formData.get('title');
      addOptimistic(title);
      await new Promise((resolve) => {
        release = resolve;
      });
      return { books: previous.books, error: 'Could not save ' + title };
    },
    { books: ['Dune'], error: '' },
  );
  const [shown, addOptimistic] = useOptimistic(state.books, (current, title) => [...current, title + ' (saving)']);
  return (
    <div>
      <form action={formAction}>
        <input name="title" defaultValue="Emma" />
        <button>add</button>
      </form>
      <ul>
        {shown.map((title) => (
          <li key={title}>{title}</li>
        ))}
      </ul>
      <p>{state.error}</p>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Books />));
const list = () => stage.querySelector('ul').innerHTML;
const original = list();

await act(async () => stage.querySelector('form').requestSubmit());
const during = list();
await act(async () => release());
const after = list();
const error = stage.querySelector('p').textContent;
console.log(during, after, error);

check('while saving, the temporary row was shown', during, '<li>Dune</li><li>Emma (saving)</li>');
check('after the failure, the list is the original again', after, original);
check('and the page says what happened', error, 'Could not save Emma');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node rollback.jsx

<li>Dune</li><li>Emma (saving)</li> <li>Dune</li> Could not save Emma
ok   while saving, the temporary row was shown
ok   after the failure, the list is the original again
ok   and the page says what happened

Optimism is a promise you have to keep honest. Show the expected result only when failure is rare, and always give the failure somewhere to be seen, as the error text above does. A row that quietly disappears with no explanation is a bug the user will report as lost data.

  • useOptimistic(state, update) shows a temporary version of state while an action runs.
  • The temporary change is dropped when the action ends; the real state shows after.
  • useFormStatus() reads whether the enclosing form is pending, from a component inside it.
  • On failure return the old state and an error; the optimistic row goes away by itself.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.