Actions, useActionState, and useOptimistic

Understand the Actions concept introduced in modern React, how to wire async functions to forms with useActionState, how to build optimistic UI with useOptimistic, and how to track pending state with useFormStatus.

Newer, version-specific React APIs — useActionState, useOptimistic, useFormStatus, and native support for passing async functions as form actions — give React a built-in way to handle pending state, optimistic updates, and error handling around data mutations, without each app reinventing its own loading/error boilerplate. These APIs require a modern React version (React 19+); this lesson clearly flags every API that is newer, and notes how they interact with a server/client component boundary in frameworks that support Server Actions.

What an 'Action' is in modern React

An Action, in modern React terminology, is simply a function — usually async — that is passed to a place React knows how to manage automatically: a form's action prop, a formAction prop on a button, or invoked through startTransition. When React manages a function this way, it automatically tracks the transition's pending state, handles errors by surfacing them through the nearest error boundary or a returned error value, and (for forms) resets uncontrolled inputs after the action completes successfully.

Before Actions, a typical form submission required a developer to manually wire together a loading boolean, an error state variable, a submit handler that calls event.preventDefault(), and manual disabling of the submit button — all repeated per form. Actions push that repetitive bookkeeping into React itself: you write the async function that performs the mutation, and React supplies the pending/error tracking around it.

Actions are 'version-specific' in the sense that <form action={asyncFunction}>, useActionState, useOptimistic, and useFormStatus are all newer React APIs — they did not exist in React 17 or 18 and require React 19 or later (or a framework, such as Next.js App Router, built on top of it).

useActionState: connecting an action to component state

useActionState (newer API, React 19+) wraps an action function and returns three things: the current state (initially whatever you passed as the initial value, then whatever the action last returned), a wrapped action to pass to a form or button, and an isPending boolean. Because the action's return value becomes the new state automatically, a component can render success messages, validation errors, or the successfully created object directly from that returned state — no separate useState call needed to store the result.

The wrapped action passed back by useActionState is what you hand to <form action={...}>. React calls your original action with two arguments: the previous state and the submitted FormData. This lets an action be aware of what happened on the previous submission (for example, to preserve a form value after a validation error) while still receiving the freshly submitted data.

isPending from useActionState removes the need to track a separate loading flag by hand — it flips to true the moment the action starts and back to false once it resolves (successfully or with a thrown error), covering the exact span of the pending transition.

useOptimistic: showing a predicted result before the server responds

useOptimistic (newer API, React 19+) lets a component render an optimistic version of some state immediately, before an async action actually completes, then automatically reverts to the real state once the action settles. It takes the current 'real' state and an update function, and returns an optimistic value to render plus a function to trigger an optimistic update.

A common pattern is combining useOptimistic with an action: when the user submits (say, a new chat message), you call the optimistic-update function synchronously to show the message immediately with a 'sending...' indicator, while the real async action runs in the background. If the action succeeds, the real state updates and matches what was already shown. If it fails, the optimistic value is discarded and the UI reverts, typically alongside an error shown via useActionState.

This is specifically about *rendering* an unconfirmed value, not about mutating persistent state — the optimistic value only exists for the duration of the pending transition and is always replaced by the authoritative state once the action resolves.

useFormStatus and pending UI without prop drilling

useFormStatus (newer API, React 19+) is a hook that reads the pending status of the nearest parent <form>, but only works when called from a component rendered *inside* that form — it reads context set up by the form itself, not by any hook in the same component that renders the form. This lets you build a reusable <SubmitButton> component that disables itself and shows a spinner while its enclosing form is submitting, without the parent form needing to pass a pending prop down manually.

This separation matters for component design: the form owns the action and the pending state (usually via useActionState), while a nested SubmitButton or FieldStatus component reads that same pending state through useFormStatus independently, keeping the two loosely coupled and reusable across different forms.

A brief note on the server/client boundary: in frameworks that support Server Actions (an async function marked 'use server'), the action passed to a form can run on the server, and React still manages the client-side pending/error/optimistic state around it the same way as a purely client-side action — the client component boundary just determines where the function body actually executes, not how the pending UI is tracked.

useActionState with a form action

useActionState is a newer API (React 19+) — it does not exist in React 18 or earlier. The action function receives (previousState, formData) and its return value becomes the new state automatically.

import { useActionState } from 'react';

type FormState = { error: string | null; success: boolean };

async function submitFeedback(
  previousState: FormState,
  formData: FormData
): Promise<FormState> {
  const text = formData.get('feedback') as string;
  if (!text || text.trim().length < 5) {
    return { error: 'Feedback must be at least 5 characters.', success: false };
  }
  await saveFeedback(text);
  return { error: null, success: true };
}

function FeedbackForm() {
  const [state, formAction, isPending] = useActionState(submitFeedback, {
    error: null,
    success: false,
  });

  return (
    <form action={formAction}>
      <textarea name="feedback" disabled={isPending} />
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p>Thanks for your feedback!</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting…' : 'Submit'}
      </button>
    </form>
  );
}

Code Examples

Clicking the button immediately shows likeCount + 1 via the optimistic value, while the real action runs in the background; once it resolves, state.likeCount from useActionState replaces the optimistic guess with the server-confirmed count. Expected result: The like count increments instantly on click, then settles to the server's real count once the request completes.

const [optimisticLikes, addOptimisticLike] = useOptimistic(state.likeCount, (current) => current + 1);

SubmitButton has no idea what action SignupForm uses — it just calls useFormStatus() to read the pending status of whichever form it happens to be rendered inside, making it reusable across any form in the app.

Common mistake: Calling useFormStatus() inside the same component that renders the <form> itself always returns pending: false — it only reads status from a form that is an ancestor of the component calling it, not the current render's own form.

import { useOptimistic, useActionState } from 'react';

type LikeState = { likeCount: number };

async function likePost(prevState: LikeState, formData: FormData): Promise<LikeState> {
  const postId = formData.get('postId') as string;
  const updated = await api.likePost(postId); // returns the real new count
  return { likeCount: updated.likeCount };
}

function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [state, formAction] = useActionState(likePost, { likeCount: initialLikes });
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    state.likeCount,
    (current: number) => current + 1
  );

  return (
    <form
      action={formAction}
      onSubmit={() => addOptimisticLike(undefined)}
    >
      <input type="hidden" name="postId" value={postId} />
      <button type="submit">▲ {optimisticLikes}</button>
    </form>
  );
}
import { useFormStatus } from 'react-dom';

function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Working…' : label}
    </button>
  );
}

function SignupForm() {
  const [state, formAction] = useActionState(signupAction, { error: null });
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      {/* SubmitButton reads the enclosing form's pending status on its own */}
      <SubmitButton label="Sign up" />
    </form>
  );
}

Data flow through an Action-driven form

useActionState, useOptimistic, and useFormStatus each manage one slice of the pending/result lifecycle around a single Action.

User submits form
        |
        v
formAction (from useActionState) is called
        |
        +--> isPending becomes true --> SubmitButton (useFormStatus) disables itself
        |
        v
your async action(previousState, formData) runs
        |
        +--> optimistic value shown immediately (useOptimistic), if used
        |
        v
action returns new state --------> becomes the new `state` from useActionState
        |
        v
isPending becomes false --> UI reflects final state (success or error)

Important Rules

useActionState, useOptimistic, useFormStatus, and passing an async function directly to <form action> are all newer, version-specific APIs requiring React 19 or later.

useFormStatus must be called from a component that is a descendant of the <form>, never from the component that renders the <form> itself.

useOptimistic's optimistic value is always temporary — it is discarded and replaced by the real state once the underlying action settles, whether it succeeds or fails.

An action's error handling can be done either by returning an error value as part of state (shown via useActionState) or by throwing, which is caught by the nearest error boundary — pick one consistently per form rather than mixing both.

Where this shows up

Actions and their supporting hooks are the standard pattern for any mutating form in a modern React or Next.js App Router application — sign-up and login forms, comment/like/follow buttons, checkout flows, and inline-editable fields all benefit from useActionState's built-in pending/error tracking and useOptimistic's instant feedback, replacing what used to be dozens of lines of manual state plumbing per form.

function LikeButton() { const [pending, setPending] = useState(false); const handleClick = async () => { setPending(true); await api.likePost(); setPending(false); }; return <button onClick={handleClick} disabled={pending}>Like</button>; } function LikeButton({ postId }: { postId: string }) { const [state, formAction, isPending] = useActionState(likePost, { likeCount: 0 }); return ( <form action={formAction}> <input type="hidden" name="postId" value={postId} /> <button disabled={isPending}>Like ({state.likeCount})</button> </form>); }

The hand-rolled version re-implements pending tracking with useState and a manual try/finally that this example even omits (a thrown error would leave pending stuck at true) — useActionState handles the pending flag and error path automatically as part of the Action's lifecycle.

[multiple-choice] What does useActionState's action function receive as arguments? A. Only the submitted FormData B. The previous state and the submitted FormData C. The DOM event and a ref to the form D. Nothing — it reads state internally

B. The previous state and the submitted FormData — React calls the action with (previousState, formData), letting the action see what happened on the prior submission while also receiving the newly submitted form data.

[true-false] useFormStatus can be called from the same component that renders the <form> element and still report that form's pending status. A. True B. False

B. False — useFormStatus only reports the pending status of an ancestor form — a component must be a descendant of the <form> for the hook to see its status; calling it in the form's own component always returns pending: false.

[multiple-choice] What happens to a useOptimistic value once its underlying action settles? A. It stays forever as the new source of truth B. It is discarded and replaced by the real, authoritative state C. It is saved to localStorage D. Nothing — useOptimistic values never change

B. It is discarded and replaced by the real, authoritative state — The optimistic value is only a temporary prediction shown during the pending transition; once the action resolves, the component's real state (whatever the action returned or updated) takes over.

Sketch (in comments or pseudocode) a useActionState-based action for a 'delete comment' button that shows an error message if the delete fails, and explain where you'd add useOptimistic to hide the comment immediately on click.

async function deleteComment(prev, formData) { const id = formData.get('id'); try { await api.deleteComment(id); return { error: null }; } catch { return { error: 'Could not delete comment.' }; } } — useOptimistic would wrap the comments array, with an update function that filters out the comment whose id matches the one just submitted, so it visually disappears immediately while deleteComment runs in the background.

The action should return { error } on failure. useOptimistic would filter the deleted comment out of a list before the server confirms the delete.