Skip to content

Actions & useActionState

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.

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.
  • useActionState returns [state, formAction, isPending] — state is whatever your action last returned, formAction is what you pass to <form action={...}>, and isPending is 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 Action lifecycle

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 action will be included in the Transition, but currently require wrapping any set functions after the await in an additional startTransition. — 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:

src/__tests__/transition-async-await.test.tsx
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.

src/__tests__/request-form-reset.test.tsx
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.

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.

PatternWhat it gives youCost
<form action={asyncFn}> + useActionStatePending/state tracking handled entirely by ReactAction 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 submissionAction-only API — call it outside a Transition/Action and React logs a warning, proven above
  • “Everything inside startTransition(async () => {...}) is part of the Transition.” Only the synchronous portion before the first await, plus anything explicitly re-wrapped in a nested startTransition after each await, actually counts.
  • “requestFormReset is just form.reset() with a different name.” It’s specifically meant to pair with Actions — calling it outside one produces a real, provable console warning, which plain form.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.
  • Does a generated startTransition(async () => {...}) callback update state after an await without a nested startTransition? That update silently escapes the Transition — check every set call that comes after any await in the same async function.
  • Is requestFormReset called from inside an event handler directly, or wrapped in startTransition/an Action? Both “work,” but only one avoids the console warning.
  • Does the action passed to useActionState really 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)).

What is a React 19 "Action"?
What does useActionState(action, initialState) return?
What arguments does the action passed to useActionState receive?
What boilerplate does useActionState replace?
Per react.dev, what must you do with a set call that comes AFTER an await inside startTransition(async () => {...})?
Is setResults(data) part of the Transition here?
function SearchResults() {
  function handleChange(nextQuery: string) {
    startTransition(async () => {
      setQuery(nextQuery)
      const data = await fetchResults(nextQuery)
      setResults(data)
    })
  }
  // ...
}
What happens when requestFormReset(form) is called from a plain event handler, outside a Transition or Action?
What is the most accurate description of what this button does?
function clear() {
  requestFormReset(formRef.current)
}

return <button type="button" onClick={clear}>Clear</button>