Skip to content

Forms & useOptimistic

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:

useFormStatus will 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:

src/__tests__/use-form-status-placement.test.tsx
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
Optimistic update, then settle

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 startTransition or 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):

src/__tests__/use-optimistic-outside-action.test.tsx
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"]
Two Action-only setters, same warning shape

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.

HookWhat it gives youCost
useFormStatusNo prop drilling for pending stateOnly works from a descendant of the <form> — not the component that renders it, proven above
useOptimisticInstant feedback before the server respondsThe setter must run inside a Transition/Action, or it warns and reverts immediately
  • “useFormStatus reads 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 — useFormStatus in the wrong component, useOptimistic outside a Transition — compile, run, and produce no crash. The only symptoms are a pending flag that’s always false, or a console warning plus a value that reverts instantly.
  • Is useFormStatus called 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 inside startTransition or a form Action — or directly from an event handler?
  • If useOptimistic is used, does the surrounding code have a try/catch around 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); }).

What does useFormStatus give a component?
Where must a component calling useFormStatus be rendered?
What does useOptimistic do when the async action fails?
While slowSave is in flight, what does pending show here?
function FormWrong() {
  const { pending } = useFormStatus()
  return (
    <form action={slowSave}>
      <button disabled={pending}>Save</button>
    </form>
  )
}
What happens if setOptimistic(newValue) (the setter from useOptimistic) is called directly from a plain onClick handler, not inside startTransition or an Action?
What is the most accurate fix for this component?
function LikeButton({ liked }: { liked: boolean }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
  function handleClick() {
    setOptimisticLiked(!optimisticLiked)
    saveLike(!optimisticLiked)
  }
  return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
}