Home / Articles

Async Forms in React 19 with use, useActionState and useOptimistic

How use(), useActionState, useFormStatus and useOptimistic replace manual loading and error flags in React 19, with the pitfalls each hook quietly hides.

Publisher: REACTAPP.TOP4 min read978 words

This article is published in English.

Sources: React 19’s New Hooks: A Practical Guide

Before React 19, nearly every fetch or form needed the same scaffolding: useState for the data, useEffect to load it, and flags for loading, errors and results. React 19 adds four APIs that move this plumbing into the framework: use(), useActionState, useFormStatus and useOptimistic. Here is what each does, how they fit together, and the constraints that are easy to miss. For a broader upgrade overview, see React 19 upgrade triage.

Reading promises during render with use()

use() is not a hook in the traditional sense. Unlike hooks, it may be called inside conditions, loops or after an early return. It reads the value of a Promise or a Context during render and cooperates with Suspense to show a pending state.

import { use, Suspense } from "react";

UserCard receives a promise and unwraps it with use; while it is unresolved, the nearest Suspense boundary in Page renders its fallback.

function UserCard({ userPromise }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}function Page({ userPromise }) {
  return (
    <Suspense fallback={<p>Loading user…</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

The parent creates the promise, the child consumes it, and Suspense owns loading. One constraint matters: the promise must be stable. A promise created during a client component's render is new on every render and triggers repeated suspension, so create it in a Server Component, a cached loader or a framework data API.

Handling submissions with useActionState

useActionState replaces hand-managed isSubmitting, error and result state. You pass an action and an initial state; you get the current state, a wrapped action for the form, and a pending flag. The action receives the previous state and the FormData, and whatever it returns becomes the next state.

import { useActionState } from "react";

Here submitFeedback validates the message, saves it, and returns either an error or a success marker, which the form renders directly.

async function submitFeedback(prevState, formData) {
  const message = formData.get("message");
  if (message.length < 5) {
    return { error: "Message is too short." };
  }
  await saveFeedback(message);
  return { success: true };
}function FeedbackForm() {
  const [state, formAction, isPending] = useActionState(submitFeedback, {});  return (
    <form action={formAction}>
      <textarea name="message" required />
      <button disabled={isPending}>
        {isPending ? "Sending…" : "Send feedback"}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p>Thanks for the feedback!</p>}
    </form>
  );
}

Because the action goes into the form's native action attribute, this supports progressive enhancement. Note that submitting before hydration only works when the action is a Server Function; a plain client function needs JavaScript loaded. Also, formData.get returns null for a missing field, so guard it in real code.

Reading form status from any child with useFormStatus

A submit button nested deep in a form often needs to know whether a submission is running, and threading isPending through every layer is tedious. useFormStatus, imported from react-dom, reads the status of the nearest parent <form>.

import { useFormStatus } from "react-dom";

SubmitButton takes no props; it picks up the pending state from its surrounding form, which makes it ideal for shared button components.

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}function SettingsForm({ action }) {
  return (
    <form action={action}>
      <input name="username" />
      <SubmitButton />
    </form>
  );
}

The hook must be called in a component rendered inside the <form>. Calling it in the component that renders the form itself returns no pending state.

Showing expected results early with useOptimistic

useOptimistic displays the anticipated outcome of an action at once while the real request runs.

import { useOptimistic, useRef } from "react";

In TodoList, the hook derives optimisticTodos from the todos prop, appending entries marked pending: true. The form action adds the optimistic item, resets the form through a ref, then awaits the real server action. Pending items render at half opacity.

function TodoList({ todos, addTodoAction }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, pending: true }]
  );
  const formRef = useRef(null);  async function handleSubmit(formData) {
    const title = formData.get("title");
    addOptimisticTodo({ id: Date.now(), title });
    formRef.current?.reset();
    await addTodoAction(formData);
  }  return (
    <div>
      <form ref={formRef} action={handleSubmit}>
        <input name="title" required />
        <button type="submit">Add</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

The mental model is precise: optimistic state lives only while the action runs. When it finishes, React discards it and shows whatever todos currently holds. On success, the parent must supply updated todos (for example via revalidation), or the item vanishes; on failure it disappears automatically, which is the rollback. Error messages still need handling. The article on useOptimistic rollback failure modes covers edge cases.

Bonus: ref as a regular prop

React 19 also lets function components receive ref as an ordinary prop, so forwardRef is unnecessary in most cases. It removes a lot of wrapping from component libraries.

Key takeaways

  • use() unwraps promises and context during render with Suspense, but the promise must be stable.
  • useActionState bundles state, result and a pending flag into one form-driven hook.
  • useFormStatus reads the enclosing form's status from a descendant, removing prop drilling.
  • useOptimistic shows expected results during an action and relies on fresh props to keep them.
  • Before rebuilding a useState and useEffect combination, check whether one of these fits.