frontend / react / react_19.md

React 19 — Actions, useActionState, useOptimistic, use(), ref as Prop

7 min read source

React 19 — Actions, useActionState, useOptimistic, use(), ref as Prop

TL;DR

React 19 added five things you should know cold: Actions (functions you pass to <form action={...}>), useActionState (manage form state + pending + errors from an action), useOptimistic (pending UI without a cache layer), use() (read a promise or context conditionally, unlocks Suspense for data), and ref as a prop (no more forwardRef for new code). Together they replace a lot of TanStack-Mutation / hand-rolled-pending-state code, and bring real progressive-enhancement back to forms.

Interview Q&A

Q: What are React 19 “Actions”?

A: Functions you pass to a <form action={fn}> (or <button formAction={fn}>). React treats them specially: they receive FormData, run async, and integrate with useActionState/useOptimistic/useFormStatus. Form submission and the pending UI become declarative.

async function createTodo(formData: FormData) {
  await fetch("/api/todos", { method: "POST", body: formData });
}

<form action={createTodo}>
  <input name="text" />
  <button type="submit">Add</button>
</form>

The browser’s native form submission is intercepted by React; the action runs. It works without JS (progressive enhancement) when paired with React Server Components / server actions in frameworks like Next.js.

Q: useActionState — what’s it for?

A: Manages state + pending + error around an action.

import { useActionState } from "react";

async function submit(prevState: State, formData: FormData) {
  try {
    const res = await fetch("/api/save", { method: "POST", body: formData });
    if (!res.ok) return { ...prevState, error: "save failed" };
    return { ...prevState, savedAt: Date.now(), error: null };
  } catch (e) {
    return { ...prevState, error: String(e) };
  }
}

function Form() {
  const [state, formAction, isPending] = useActionState(submit, { error: null, savedAt: null });
  return (
    <form action={formAction}>
      <input name="title" />
      <button disabled={isPending}>{isPending ? "Saving…" : "Save"}</button>
      {state.error && <p className="error">{state.error}</p>}
    </form>
  );
}

The action receives (prevState, formData) and returns the next state — same signature as a reducer, naturally typed. isPending is set by React while the action runs. Replaces the manual useState({ pending: false, error: null }) boilerplate.

Q: useFormStatus — when does it matter?

A: Read the parent form’s pending state from inside a child without prop-drilling:

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

// Used inside any <form>
<form action={save}><input /><SubmitButton /></form>

Lets reusable buttons / spinners adapt to the form they’re inside without each form passing pending down.

Q: useOptimistic — what problem does it solve?

A: Show the expected state immediately while the real action runs; revert if it fails. Same idea as TanStack Query’s optimistic updates (../11_apis_data_fetching/03_optimistic_updates.md) but built into React with no cache layer needed.

import { useOptimistic } from "react";

function Likes({ post }: { post: Post }) {
  const [optimisticPost, addOptimistic] = useOptimistic(
    post,
    (current, delta: number) => ({ ...current, likeCount: current.likeCount + delta }),
  );

  async function toggleLike() {
    addOptimistic(optimisticPost.liked ? -1 : 1);
    await fetch(`/api/posts/${post.id}/like`, { method: "POST" });
  }

  return <button onClick={toggleLike}>{optimisticPost.likeCount} </button>;
}

Behavior:

  • Calling addOptimistic(arg) applies the reducer immediately.
  • The optimistic state is live until the surrounding action/transition completes.
  • If the action throws or completes, the optimistic state reverts to the real value.

Best for: simple like/toggle/reorder UIs where you’d otherwise reach for TanStack Query just for the optimistic feature.

Q: use() — what is it, why care?

A: A hook that reads a value from a Promise or Context. Unlike other hooks, use() can be called conditionally and inside loops. This is React’s first-class API for Suspense for data.

import { use, Suspense } from "react";

function UserName({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);   // suspends until resolved
  return <span>{user.name}</span>;
}

<Suspense fallback={<Skeleton />}>
  <UserName userPromise={fetchUser(id)} />
</Suspense>

When userPromise is pending, the component suspends — the nearest <Suspense> boundary shows its fallback. When it resolves, React re-renders. Errors throw to the nearest error boundary.

Reading context with use(MyContext) is the conditional version of useContext(MyContext):

function Maybe({ flag }: { flag: boolean }) {
  if (flag) {
    const theme = use(ThemeContext);   // ok — conditional read
    return <span>{theme}</span>;
  }
  return null;
}

use() is the foundation for the RSC + Suspense + Server Actions flow. In server components, use(promise) is how server data crosses to the client tree.

Q: ref as a prop — what changed?

A: In React 19, function components accept ref as a regular prop. No more forwardRef.

// React 19+
function MyInput({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> } & InputProps) {
  return <input ref={ref} {...props} />;
}

// Usage — works
<MyInput ref={inputRef} />
// React 18 and earlier — needed forwardRef
const MyInput = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input ref={ref} {...props} />
));

This doesn’t deprecate forwardRef — old code keeps working. But for new components, ref as a prop is cleaner, composes with generics better (no more cast workaround for forwardRef<typeof generic>), and removes one of the most annoying React typing patterns.

Q: Server Actions (RSC + form actions) — what’s the senior framing?

A: In a framework with React Server Components (Next.js App Router, Remix), you can mark a function "use server" and pass it as a form action; the client never sees it, but calling it goes through the framework’s server-action endpoint:

// app/todo/page.tsx (server component)
async function addTodo(formData: FormData) {
  "use server";
  await db.todos.insert({ text: formData.get("text") });
  revalidatePath("/todo");
}

export default function TodoPage() {
  return (
    <form action={addTodo}>
      <input name="text" />
      <button>Add</button>
    </form>
  );
}

Wire: framework auto-generates an endpoint behind the scenes; the form submits to it; the action runs on the server with full DB/secret access; the response triggers a re-render (or revalidation).

The senior takeaway: this unifies “form submission” with “server mutation” in a way that previously required hand-wiring useMutation + cache invalidation. Plus it enables progressive enhancement — the form works (sends a normal POST) even before React hydrates.

Q: How do these features replace older patterns?

A:

Old pattern New React 19 idiom
useState + try/catch + pending flag for form submit useActionState
useState to disable button while pending useFormStatus
TanStack Query’s useMutation for purely-additive optimistic useOptimistic
forwardRef wrapper for ref-forwarding ref as a prop
useEffect + fetch + setState chain for SSR data use(promise) inside <Suspense>
Hand-wired server endpoint + mutation Server Action ("use server") + <form action>

TanStack Query / SWR are still the answer for server-state caching (refetch on focus, invalidation across components). The new APIs are for the act of mutating + the local pending UI.

Gotchas / edge cases

  • action prop on <form> overrides the browser default — React intercepts. Pass a function (or a string URL for traditional submission).
  • useActionState’s initial state must be serializable if SSR — it’s encoded into the HTML.
  • useOptimistic reverts on action completion, not on success. If you keep the optimistic state outside the action, you have to revert manually.
  • use(promise) requires the same Promise reference across renders — wrap with useMemo or pass from a parent that creates it once. A new Promise each render = infinite suspend → resume.
  • ref as a prop in TypeScript — annotate as React.Ref<T> or React.RefObject<T>. Forgetting forces any.
  • Server Actions need a framework — bare React doesn’t ship the wire protocol; Next.js / Remix do.
  • useFormStatus only reads the parent form — won’t work outside one. Throws if used without a <form> parent in dev.

What a senior is expected to say

  • “React 19 made form submission and pending UI first-class. useActionState for form state + pending + error, useFormStatus for nested submit buttons, useOptimistic for local optimistic UI without a cache layer.”
  • use(promise) is the data hook that unlocks Suspense for data — and unlike other hooks, it can be conditional. Pair with <Suspense> for declarative async UI.”
  • ref is just a prop now — for new components, no more forwardRef. Old components keep working.”
  • “Server Actions unify mutation + revalidation when paired with a framework. They give you progressive enhancement back — forms work pre-hydration.”
  • “These don’t replace TanStack Query for server-state caching; they replace the boilerplate around the mutation itself.”

Cross-references

Further reading