Forms & useOptimistic
useFormStatus: pending, without drilling
Section titled “useFormStatus: pending, without drilling”You often want a submit button to know if its form is pending — to disable itself and show a spinner. Passing isPending down through every intermediate component is tedious. useFormStatus (from react-dom) lets a component read the status of the nearest parent <form> directly:
import { useFormStatus } from 'react-dom';
// This button reads the enclosing form's status — no props needed.function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? 'Saving…' : 'Save'} </button> );}
function ProfileForm({ action }) { return ( <form action={action}> <input name="name" /> <SubmitButton /> {/* knows the form is pending, with zero prop drilling */} </form> );}The one rule: useFormStatus reads the parent form, so SubmitButton must be rendered inside the <form>, not be the component that renders the form itself. react.dev states this precisely:
useFormStatuswill only return status information for a parent<form>. It will not return status information for any<form>rendered in that same component or children components. — react.dev/reference/react-dom/hooks/useFormStatus
That rule is easy to violate without any error, because both the wrong and the right version compile and render fine — the difference only shows up while a submission is actually in flight:
import { describe, it, expect } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { useFormStatus } from 'react-dom'
function slowSave(_formData: FormData) { return new Promise<void>((resolve) => setTimeout(resolve, 60))}
// ❌ useFormStatus called in the SAME component that renders <form>.function FormWrong() { const { pending } = useFormStatus() return ( <form action={slowSave}> <button type="submit">{pending ? 'Saving (wrong)...' : 'Save (wrong)'}</button> </form> )}
// ✅ useFormStatus called from a child of <form>.function SubmitStatus() { const { pending } = useFormStatus() return <button type="submit">{pending ? 'Saving (right)...' : 'Save (right)'}</button>}function FormRight() { return ( <form action={slowSave}> <SubmitStatus /> </form> )}
describe('useFormStatus must be called from a child of <form>', () => { it('same-component placement never reports pending: true', async () => { render(<FormWrong />) fireEvent.click(screen.getByText('Save (wrong)')) // Give the pending Transition a moment to commit if it were going to. await new Promise((r) => setTimeout(r, 20)) expect(screen.queryByText('Saving (wrong)...')).toBeNull() expect(screen.getByText('Save (wrong)')).toBeTruthy() })
it('child-of-form placement reports pending: true while the action is in flight', async () => { render(<FormRight />) fireEvent.click(screen.getByText('Save (right)')) await waitFor(() => expect(screen.getByText('Saving (right)...')).toBeTruthy()) await waitFor(() => expect(screen.getByText('Save (right)')).toBeTruthy()) })})Both tests pass: FormWrong’s button text never changes to "Saving (wrong)..." at any point during the submission, while FormRight’s does — proof, not just documentation, that the same-component placement silently never reports pending: true.
useOptimistic: show the result before it’s real
Section titled “useOptimistic: show the result before it’s real”For a snappy UI, you often want to show the expected outcome immediately and correct it only if the request fails. useOptimistic gives you an optimistic value that you set instantly and that React automatically reverts to the real value once the action settles:
import { useOptimistic } from 'react';
function Thread({ messages, sendMessage }) { const [optimisticMessages, addOptimistic] = useOptimistic( messages, (current, newText) => [...current, { text: newText, sending: true }], );
async function formAction(formData) { const text = formData.get('message'); addOptimistic(text); // show it immediately, marked "sending" await sendMessage(text); // real request; React reconciles when it resolves }
return ( <> {optimisticMessages.map((m, i) => ( <p key={i}>{m.text} {m.sending && <small>(sending…)</small>}</p> ))} <form action={formAction}> <input name="message" /> <button type="submit">Send</button> </form> </> );}The message appears the instant you submit. When sendMessage resolves, React drops the optimistic entry in favor of the real state; if it fails, React automatically reverts, so you don’t manually roll back.
sequenceDiagram participant U as User participant O as useOptimistic participant S as server action U->>O: submit (addOptimistic) O->>U: show optimistic value instantly O->>S: await real request S-->>O: success → keep real state Note over O,U: on failure React reverts automatically
Together these two hooks make a form feel instant: useOptimistic for immediate feedback, useFormStatus for the pending affordance on the button — both without threading state through props.
useOptimistic outside a Transition or Action: a real warning, not just a style nit
Section titled “useOptimistic outside a Transition or Action: a real warning, not just a style nit”The setter useOptimistic gives you (addOptimistic above) is, like requestFormReset, an Action-only API. Call it directly from a plain event handler — not inside startTransition and not inside a form Action — and react.dev documents exactly what happens:
The optimistic setter function must be called inside
startTransitionor within an Action. Calling the setter outside an Action will cause the optimistic state to revert immediately because there is no Transition holding it. — react.dev/reference/react/useOptimistic
Proven directly, including the exact console message (captured from a real run, not paraphrased from the docs — note it’s lowercase “transition or action”, unlike some of react.dev’s own prose):
import { describe, it, expect, vi, afterEach } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { useOptimistic, useState } from 'react'
function BadOptimistic() { const [liked] = useState(false) const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() { // ❌ called directly in an event handler — not inside startTransition or an Action. setOptimisticLiked(true) }
return <button onClick={handleClick}>{optimisticLiked ? 'liked' : 'like'}</button>}
describe('useOptimistic called outside a Transition/Action', () => { afterEach(() => { vi.restoreAllMocks() })
it('logs the "outside a Transition or Action" console.error and the optimistic value reverts immediately', async () => { const errors: string[] = [] vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(' ')) })
render(<BadOptimistic />) fireEvent.click(screen.getByText('like'))
await waitFor(() => { expect(errors.length).toBeGreaterThan(0) }) expect(errors.some((e) => e.includes('outside a transition or action'))).toBe(true) // The optimistic value briefly renders, then reverts because no Transition holds it. expect(screen.getByText('like')).toBeTruthy() })})The button’s text ends up back at 'like' — the exact “revert immediately” behavior the docs describe — and the console carries the real warning text. This is the same family of bug as calling requestFormReset outside an Action (previous lesson): both compile, both “sort of work,” and both quietly tell you they’re being misused.
flowchart LR a["setOptimistic(x) outside startTransition/Action"] --> b["console.error: outside a transition or action"] b --> c["value reverts immediately (no Transition holding it)"] d["setOptimistic(x) inside startTransition/Action"] --> e["no warning value holds until settle"]
Mental Model
Section titled “Mental Model”useFormStatus and useOptimistic are both reads on an in-flight Transition, not independent pieces of state you manage yourself. useFormStatus reads whether the ancestor form’s Transition is still running; useOptimistic’s setter needs an active Transition to have somewhere to “hold” its value until that Transition settles. Neither hook errors loudly when used wrong — both degrade to “quietly does the less useful thing,” which is exactly why proving the behavior (not just reading about it) matters here.
Trade-offs
Section titled “Trade-offs”| Hook | What it gives you | Cost |
|---|---|---|
useFormStatus | No prop drilling for pending state | Only works from a descendant of the <form> — not the component that renders it, proven above |
useOptimistic | Instant feedback before the server responds | The setter must run inside a Transition/Action, or it warns and reverts immediately |
Common Misconceptions
Section titled “Common Misconceptions”- “
useFormStatusreads whichever form is nearest in the file.” It reads the nearest form ancestor in the render tree. The component that renders<form>itself is not that form’s descendant — proven above with two components that look almost identical. - “
useOptimistic’s setter can be called from anywhere, like a normal state setter.” It specifically requires an active Transition or Action; called from a plain event handler, it warns and the optimistic value reverts before the user can even see it settle. - “If nothing throws, the code is correct.” Both bugs on this page —
useFormStatusin the wrong component,useOptimisticoutside a Transition — compile, run, and produce no crash. The only symptoms are apendingflag that’s alwaysfalse, or a console warning plus a value that reverts instantly.
Reading AI-generated code
Section titled “Reading AI-generated code”- Is
useFormStatuscalled in a component that is a child of<form>, or in the same component that renders the<form>tag? - Is
useOptimistic’s setter (addOptimistic/setOptimistic/ whatever it’s named) called from insidestartTransitionor a form Action — or directly from an event handler? - If
useOptimisticis used, does the surrounding code have atry/catcharound the action call, so the rejection path actually runs and the UI’s error state gets set? - Does the confirmed state only update inside the success path, so a failed action can never “confirm” data that was never written?
🐛 Spot the bug
function LikeButton({ liked }: { liked: boolean }) {const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() { setOptimisticLiked(!optimisticLiked) saveLike(!optimisticLiked)}
return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>}Show the answer
setOptimisticLiked is called directly inside a plain onClick handler — not inside startTransition and not inside a form Action. Per react.dev, “Calling the setter outside an Action will cause the optimistic state to revert immediately because there is no Transition holding it.” Proven above: the button briefly shows the optimistic label, then reverts, while the console logs the real warning text ("An optimistic state update occurred outside a transition or action...") — so the “instant feedback” this code is trying to achieve never actually holds. The fix is to wrap the call: startTransition(() => { setOptimisticLiked(!optimisticLiked); saveLike(!optimisticLiked); }).