Skip to content
elephantoo

React 19: Actions, useActionState, useOptimistic & use

Lesson 22 of 30 20 min read

Form actions, pending states with useFormStatus, optimistic updates, the use() API and other React 19 changes.


React 19 is the biggest update in years. Its headline feature is Actions: a built-in way to handle async work like form submissions, with pending states, errors and optimistic updates built in. It also simplifies a lot of everyday code — ref is now a normal prop, context providers are shorter, and you can put <title> tags anywhere. This lesson tours the features you'll use most, including the 19.2 additions <Activity> and useEffectEvent.

Actions: async transitions#

In React 19, the function you pass to startTransition can be async. React calls these Actions. It tracks the pending state for you and sends thrown errors to the nearest error boundary:

JSX
import { useState, useTransition } from "react";

function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  function handleSubmit() {
    startTransition(async () => {
      const err = await updateName(name);
      if (err) {
        setError(err);
        return;
      }
      setError(null);
      // redirect, show a toast, etc.
    });
  }

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? "Saving…" : "Update"}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

No more manual setIsLoading(true) / finally { setIsLoading(false) }.

Form actions#

<form> now accepts a function as its action. React calls it with the form's FormData when the form is submitted — no preventDefault, no controlled inputs required — and resets uncontrolled fields after a successful submission:

JSX
function Newsletter() {
  async function subscribe(formData) {
    const email = formData.get("email");
    await fetch("/api/subscribe", { method: "POST", body: JSON.stringify({ email }) });
  }

  return (
    <form action={subscribe}>
      <input name="email" type="email" required />
      <button>Subscribe</button>
    </form>
  );
}

Buttons can also have their own action with formAction, e.g. <button formAction={saveDraft}>Save draft</button>.

useActionState: results, errors and pending#

Most forms need to show a result: "Saved!", or a validation error. useActionState wraps an action and keeps track of what it returned:

JSX
import { useActionState } from "react";

async function signUp(previousState, formData) {
  const email = String(formData.get("email") ?? "");
  const password = String(formData.get("password") ?? "");

  if (!email.includes("@")) return { error: "Enter a valid email", email };
  if (password.length < 8) return { error: "Password must be at least 8 characters", email };

  await new Promise((r) => setTimeout(r, 800)); // pretend to call an API
  return { success: `Welcome aboard, ${email}!` };
}

export default function SignUpForm() {
  const [state, formAction, isPending] = useActionState(signUp, { error: null });

  if (state.success) return <p>{state.success}</p>;

  return (
    <form action={formAction}>
      <input name="email" type="email" placeholder="Email" defaultValue={state.email} />
      <input name="password" type="password" placeholder="Password" />
      <button disabled={isPending}>{isPending ? "Creating account…" : "Sign up"}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Key points:

  • Your action receives (previousState, formData) and returns the next state.
  • useActionState returns [state, formAction, isPending].
  • Submissions are queued: if the user submits twice, the second runs after the first, with the first's result as previousState.
  • Because uncontrolled fields reset after the action, use defaultValue={state.email} to keep what the user typed when there's an error.

useFormStatus: pending state for design-system buttons#

A reusable submit button shouldn't need an isPending prop. useFormStatus (from react-dom) reads the status of the parent form:

JSX
import { useFormStatus } from "react-dom";

export function SubmitButton({ children, pendingText = "Saving…" }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? pendingText : children}
    </button>
  );
}

function CommentForm({ addComment }) {
  return (
    <form action={addComment}>
      <textarea name="text" required />
      <SubmitButton pendingText="Posting…">Post comment</SubmitButton>
    </form>
  );
}

useFormStatus also returns data (the submitted FormData), method and action. It only works in a component rendered inside a <form>.

useOptimistic: instant feedback#

Users expect a "like" or a sent message to appear instantly. useOptimistic shows the expected result straight away while the real request runs:

JSX
import { useOptimistic, useState } from "react";

async function sendToServer(text) {
  await new Promise((r) => setTimeout(r, 1000));
  return { id: crypto.randomUUID(), text };
}

export default function Chat() {
  const [messages, setMessages] = useState([{ id: "1", text: "Hi there! 👋" }]);
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (current, newText) => [...current, { id: "pending", text: newText, sending: true }]
  );

  async function sendMessage(formData) {
    const text = formData.get("message");
    addOptimisticMessage(text); // shows immediately
    const saved = await sendToServer(text); // real request
    setMessages((prev) => [...prev, saved]); // real state replaces the optimistic one
  }

  return (
    <>
      <ul>
        {optimisticMessages.map((m) => (
          <li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
            {m.text} {m.sending && <small>(sending…)</small>}
          </li>
        ))}
      </ul>
      <form action={sendMessage}>
        <input name="message" required autoComplete="off" />
        <button>Send</button>
      </form>
    </>
  );
}

useOptimistic(state, updateFn) returns the optimistic value and a function to add an optimistic update. The optimistic value is only shown while the Action is running. When it finishes, React renders the real messages again — including the saved message on success, or without it if the request threw (show an error too).

Tip: call the optimistic setter inside an Action — a form action, or a function passed to startTransition.

use: read promises and context#

use is a new API that reads a resource during render:

JSX
import { use, Suspense } from "react";

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // suspends until resolved
  return comments.map((c) => <p key={c.id}>{c.body}</p>);
}

function Post({ commentsPromise }) {
  return (
    <Suspense fallback={<p>Loading comments…</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

Unlike hooks, use can be called conditionally and inside loops — handy for context:

JSX
function Heading({ children }) {
  if (!children) return null;
  const theme = use(ThemeContext); // ✅ allowed after an early return
  return <h1 style={{ color: theme.color }}>{children}</h1>;
}

Remember the promise must be created outside render (or cached), as covered in Suspense & lazy loading.

Quality-of-life improvements#

ref is a prop. Function components receive ref like any other prop — forwardRef is no longer needed:

JSX
function TextInput({ label, ref, ...props }) {
  return (
    <label>
      {label} <input ref={ref} {...props} />
    </label>
  );
}

Ref callbacks can return a cleanup function, called when the element is removed:

JSX
<div
  ref={(node) => {
    const observer = new ResizeObserver(() => console.log(node.getBoundingClientRect().width));
    observer.observe(node);
    return () => observer.disconnect();
  }}
/>

Context as a provider. Render <ThemeContext value="dark"> instead of <ThemeContext.Provider value="dark">.

useDeferredValue initial value. useDeferredValue(value, initialValue) lets the first render use a cheap placeholder.

Document metadata#

Render <title>, <meta> and <link> anywhere in your components — React hoists them into <head>:

JSX
function LessonPage({ lesson }) {
  return (
    <article>
      <title>{`${lesson.title} · Elephantoo`}</title>
      <meta name="description" content={lesson.summary} />
      <link rel="canonical" href={`https://elephantoo.com/react/${lesson.slug}`} />
      <h1>{lesson.title}</h1>
    </article>
  );
}

React also manages stylesheets with a precedence prop (<link rel="stylesheet" href="/chart.css" precedence="default" />) — it inserts them in order, de-duplicates them and waits for them to load before revealing Suspense content. react-dom exports resource hints such as preload, preconnect and preinit for fonts and scripts.

<Activity>: hide without losing state (19.2)#

Conditionally rendering a panel ({open && <Panel />}) destroys its state when it closes. <Activity> hides it instead:

JSX
import { Activity, useState } from "react";

function Sidebar() {
  const [isOpen, setIsOpen] = useState(true);
  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle sidebar</button>
      <Activity mode={isOpen ? "visible" : "hidden"}>
        <Filters /> {/* typed text, scroll position and state survive while hidden */}
      </Activity>
    </>
  );
}

In "hidden" mode React hides the children with display: none, cleans up their effects, and defers their updates until React has nothing else to do. It's great for tabs and for pre-rendering a page the user is likely to visit next.

useEffectEvent (19.2)#

useEffectEvent separates "reactive" logic from "event" logic inside effects — read the latest props without re-running the effect:

JSX
import { useEffect, useEffectEvent } from "react";

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme); // always sees the latest theme
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // changing the theme no longer reconnects
}

Upgrading from React 18#

  • Update both packages: npm install react@latest react-dom@latest.
  • Removed APIs: ReactDOM.render and hydrate (use createRoot/hydrateRoot), propTypes and defaultProps on function components (use TypeScript and default parameters), string refs, and legacy context.
  • The react-codemod tool can automate many of these changes.

Common mistakes#

  • Calling useFormStatus in the same component that renders the <form> — it needs to be in a child.
  • Forgetting that useActionState actions receive previousState first, then formData.
  • Expecting optimistic updates to persist after the Action ends — update real state too.
  • Passing a freshly created promise to use on every render.
  • Still using forwardRef and .Provider in new code (they work, but aren't needed).

What's next#

Context and reducers cover a lot, but large apps often use a dedicated store. Next: state management with Zustand and Redux Toolkit.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does useActionState(action, initialState) return?

  2. 2.Where must a component that calls useFormStatus() be rendered?

  3. 3.What does useOptimistic let you do?

Finished reading?

Mark this lesson complete to track your progress.