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
actionprop 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.useOptimisticreverts 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 withuseMemoor pass from a parent that creates it once. A new Promise each render = infinite suspend → resume.refas a prop in TypeScript — annotate asReact.Ref<T>orReact.RefObject<T>. Forgetting forcesany.- Server Actions need a framework — bare React doesn’t ship the wire protocol; Next.js / Remix do.
useFormStatusonly 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.
useActionStatefor form state + pending + error,useFormStatusfor nested submit buttons,useOptimisticfor 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.” - “
refis just a prop now — for new components, no moreforwardRef. 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
- Optimistic updates with TanStack Query (the cache-layer alternative): ../11_apis_data_fetching/03_optimistic_updates.md
- Suspense for data (deeper): suspense_for_data.md
- Server Components: server_components.md
- React typing (generics, polymorphic, ref-as-prop): ../04_typescript/08_react_typing.md
Further reading
- React 19 release post: https://react.dev/blog/2024/04/25/react-19
useActionState: https://react.dev/reference/react/useActionStateuseOptimistic: https://react.dev/reference/react/useOptimisticuse(): https://react.dev/reference/react/useuseFormStatus: https://react.dev/reference/react-dom/hooks/useFormStatus