Actions & useActionState
The pattern React 19 deletes
Section titled “The pattern React 19 deletes”Before Actions, submitting a form meant hand-wiring three pieces of state and remembering to update all of them:
// The old way — lots of manual bookkeeping.function ChangeName() { const [name, setName] = useState(''); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null);
async function handleSubmit(e) { e.preventDefault(); setIsPending(true); setError(null); try { await updateName(name); } catch (err) { setError(err.message); } finally { setIsPending(false); // must not forget this } } // ...form wired to handleSubmit}Every async form in the app repeats this: a loading flag, an error slot, a try/catch/finally. It’s boilerplate, and forgetting the finally leaves a spinner stuck forever.
Actions: async functions passed to a form
Section titled “Actions: async functions passed to a form”An Action is just an async function you hand to a <form> via the action prop. React calls it on submit, passes it the FormData, and manages the pending state and form reset for you:
<form action={async (formData) => { await updateName(formData.get('name'));}}> <input name="name" /> <button type="submit">Update</button></form>No onSubmit, no preventDefault, no manual FormData assembly. But you still want pending and error state to show the user what’s happening — that’s what useActionState adds.
useActionState: pending, error, and result in one call
Section titled “useActionState: pending, error, and result in one call”useActionState(action, initialState) wraps your action and returns a three-element tuple: the current state, a wrapped action to pass to the form, and a pending boolean.
import { useActionState } from 'react';
function ChangeName() { const [error, submitAction, isPending] = useActionState( async (previousState, formData) => { const error = await updateName(formData.get('name')); if (error) { return error; // returned value becomes the new state } redirect('/profile'); return null; // success: clear the error }, null, // initial state );
return ( <form action={submitAction}> <input type="text" name="name" /> <button type="submit" disabled={isPending}>Update</button> {error && <p>{error}</p>} </form> );}Read the shape carefully — it’s the part people get wrong:
- The action receives
(previousState, formData)and returns the next state. useActionStatereturns[state, formAction, isPending]—stateis whatever your action last returned,formActionis what you pass to<form action={...}>, andisPendingis managed for you.
sequenceDiagram participant U as User participant F as form action participant A as your async action U->>F: submit F->>F: isPending = true F->>A: action(prevState, formData) A-->>F: returns next state F->>F: isPending = false, state updated F->>U: re-render with new state
The whole three-piece dance — loading flag, error slot, try/catch/finally — collapses into one hook that can’t forget to reset the spinner.
The startTransition(async () => {...}) pitfall
Section titled “The startTransition(async () => {...}) pitfall”Under the hood, an Action’s pending state is tracked as a Transition — that’s how isPending stays accurate without you managing a flag by hand. The moment you write your own async function and hand it to startTransition directly (a common pattern for a “do this without blocking the UI” helper that isn’t a form Action), you can hit a sharp edge react.dev states plainly:
Any async calls that are awaited in the
actionwill be included in the Transition, but currently require wrapping anysetfunctions after theawaitin an additionalstartTransition. — react.dev/reference/react/useTransition
In plain terms: a setState call before an await inside startTransition(async () => {...}) stays part of the Transition; a setState call after an await does not, unless you wrap it in a second, nested startTransition. React loses track of the async scope across the await — this is a JavaScript limitation (no AsyncContext yet), not a bug.
This matters concretely with Suspense: a Transition update that suspends keeps the old content on screen until the data is ready; a non-Transition update that suspends shows the fallback immediately. So the exact same setShow(true) either keeps your UI stable or flashes a loading state, purely depending on whether it’s re-wrapped after the await:
import { describe, it, expect } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { startTransition, use, useState, Suspense } from 'react'
function makeResource() { let resolve!: () => void const promise = new Promise<void>((res) => { resolve = res }) return { promise, resolve }}
function Panel({ resource }: { resource: { promise: Promise<void> } }) { use(resource.promise) return <p>Loaded</p>}
function App({ rewrap }: { rewrap: boolean }) { const [show, setShow] = useState(false) const [resource] = useState(() => makeResource()) ;(globalThis as any).__resolve = resource.resolve
function handleClick() { startTransition(async () => { await Promise.resolve() // simulate an awaited async step if (rewrap) { // ✅ re-wrapped: this setState stays part of the Transition startTransition(() => setShow(true)) } else { // ❌ not re-wrapped: this setState is a normal, non-Transition update setShow(true) } }) }
return ( <> <button onClick={handleClick}>go</button> <Suspense fallback={<p>Loading fallback</p>}> {show ? <Panel resource={resource} /> : <p>Old content</p>} </Suspense> </> )}
describe('startTransition(async () => {...}) — setState after await', () => { it('WITHOUT re-wrapping: the update after await is not a Transition, so Suspense shows the fallback immediately', async () => { render(<App rewrap={false} />) fireEvent.click(screen.getByText('go')) await waitFor(() => expect(screen.getByText('Loading fallback')).toBeTruthy()) ;(globalThis as any).__resolve() await waitFor(() => expect(screen.getByText('Loaded')).toBeTruthy()) })
it('WITH re-wrapping: the update after await stays a Transition, so Suspense keeps the old content until ready', async () => { render(<App rewrap={true} />) fireEvent.click(screen.getByText('go')) // Give the microtask queue a chance to run; old content must still be there. await new Promise((r) => setTimeout(r, 20)) expect(screen.getByText('Old content')).toBeTruthy() expect(screen.queryByText('Loading fallback')).toBeNull() ;(globalThis as any).__resolve() await waitFor(() => expect(screen.getByText('Loaded')).toBeTruthy()) })})Both cases pass — that’s the point. The bug isn’t a crash; it’s a UX regression that “looks correct” in every code review (it compiles, it runs, the data eventually shows up) and only reveals itself as an unwanted loading flash under a real user’s network conditions.
requestFormReset: resetting a form outside of a submission
Section titled “requestFormReset: resetting a form outside of a submission”React auto-resets a form’s uncontrolled fields after a form Action succeeds — but sometimes you need to reset a form without submitting it (a “Clear” button, or resetting after some other event). requestFormReset(form) does that imperatively. Like useOptimistic’s setter, it’s an Action-only API: call it outside a Transition/Action and it still works, but React logs a warning.
import { describe, it, expect, vi, afterEach } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { requestFormReset } from 'react-dom'import { startTransition, useRef } from 'react'
function ClearableForm({ insideTransition }: { insideTransition: boolean }) { const formRef = useRef<HTMLFormElement>(null) function clear() { if (insideTransition) { // ✅ requestFormReset is an Action-only API, like useOptimistic. startTransition(() => requestFormReset(formRef.current!)) } else { // ❌ called directly from an event handler. requestFormReset(formRef.current!) } } return ( <form ref={formRef}> <input name="title" defaultValue="draft title" /> <button type="button" onClick={clear}>Clear</button> </form> )}
describe('requestFormReset', () => { afterEach(() => vi.restoreAllMocks())
it('outside a Transition/Action: still resets the field, but logs a console.error', async () => { const errors: string[] = [] vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(' ')) }) render(<ClearableForm insideTransition={false} />) const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: 'something the user typed' } }) fireEvent.click(screen.getByText('Clear')) expect(input.value).toBe('draft title') await waitFor(() => expect(errors.length).toBeGreaterThan(0)) expect(errors.some((e) => e.includes('requestFormReset was called outside a transition or action'))).toBe(true) })
it('inside startTransition: resets the field with no warning', async () => { const errors: string[] = [] vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(' ')) }) render(<ClearableForm insideTransition={true} />) const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: 'something the user typed' } }) fireEvent.click(screen.getByText('Clear')) await waitFor(() => expect(input.value).toBe('draft title')) expect(errors.length).toBe(0) })})Both tests pass against React 19.3.0 — including the exact console message, captured verbatim from a real run: requestFormReset was called outside a transition or action. To fix, move the update to an action, or wrap with startTransition. That message isn’t in react.dev’s prose docs for requestFormReset at the time of writing; it’s the kind of detail you only get by actually running the code, which is why this course does.
Mental Model
Section titled “Mental Model”An Action’s isPending is really “is there an active Transition wrapping this async work” — which is exactly why the docs’ async-Transition caveat matters here specifically. Anything that reads or writes state after an await inside startTransition needs its own nested startTransition to stay inside that Transition; anything before the first await is already covered.
Trade-offs
Section titled “Trade-offs”| Pattern | What it gives you | Cost |
|---|---|---|
<form action={asyncFn}> + useActionState | Pending/state tracking handled entirely by React | Action signature is (prevState, formData), not (formData) |
Hand-rolled startTransition(async () => {...}) | Full control over what counts as “pending” | Must manually re-wrap every set call after an await, or it silently escapes the Transition |
requestFormReset(form) | Imperative reset outside of a submission | Action-only API — call it outside a Transition/Action and React logs a warning, proven above |
Common Misconceptions
Section titled “Common Misconceptions”- “Everything inside
startTransition(async () => {...})is part of the Transition.” Only the synchronous portion before the firstawait, plus anything explicitly re-wrapped in a nestedstartTransitionafter eachawait, actually counts. - “
requestFormResetis justform.reset()with a different name.” It’s specifically meant to pair with Actions — calling it outside one produces a real, provable console warning, which plainform.reset()never would. - “If a Transition doesn’t error, it did what I expected.” The async-await pitfall above never throws or logs anything on its own — the only symptom is a Suspense fallback flashing where old content should have stayed. Silence is not proof of correctness here.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated
startTransition(async () => {...})callback update state after anawaitwithout a nestedstartTransition? That update silently escapes the Transition — check everysetcall that comes after anyawaitin the same async function. - Is
requestFormResetcalled from inside an event handler directly, or wrapped instartTransition/an Action? Both “work,” but only one avoids the console warning. - Does the action passed to
useActionStatereally take(previousState, formData), not just(formData)? (Covered fully in this lesson’s earlier section — still the single most common mistake to re-check.)
🐛 Spot the bug
function SearchResults() {const [query, setQuery] = useState('')const [results, setResults] = useState<string[]>([])
function handleChange(nextQuery: string) { startTransition(async () => { setQuery(nextQuery) const data = await fetchResults(nextQuery) setResults(data) // looks fine — it's inside startTransition, right? })}
return <input onChange={(e) => handleChange(e.target.value)} />}Show the answer
setResults(data) runs after await fetchResults(nextQuery), so despite being lexically inside the startTransition(async () => {...}) callback, it is not part of the Transition — react.dev is explicit that state updates after an await require their own nested startTransition to stay marked as one. If SearchResults renders something that suspends based on results, this will show a loading fallback instead of keeping the previous results on screen, exactly like the proven test above. The fix: wrap the line in startTransition(() => setResults(data)).