React 19 introduced three hooks that fundamentally change how production React apps handle async data and forms: use(), useOptimistic, and useActionState. Used together, they replace huge amounts of boilerplate that previously took libraries like SWR, React Query, and Redux Toolkit Query to handle.
This is the production playbook for each.
use(). Reading Promises and Context Conditionally
The use() hook reads a Promise or a Context inside a render. Unlike useContext, it can be called conditionally. Unlike useEffect, it integrates with Suspense. the component suspends until the Promise resolves.
import { use, Suspense } from "react";
function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise); // suspends until resolved
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
export default function Page() {
const postsPromise = fetchPosts(); // start the fetch eagerly
return (
<Suspense fallback={<Skeleton />}>
<PostList postsPromise={postsPromise} />
</Suspense>
);
}
The pattern is start the fetch outside the component, pass the Promise in, read it with use(). This decouples when the request starts from when the component reads it, enabling parallel data loading patterns that were painful before.
export default function Page() {
// Both requests start in parallel
const userPromise = fetchUser();
const postsPromise = fetchPosts();
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserCard userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostList postsPromise={postsPromise} />
</Suspense>
</>
);
}
Two requests, started simultaneously, each with its own Suspense boundary. Whichever finishes first renders first.
Gotcha: use(promise) only works when the Promise is created outside the component (or higher up the tree). Creating a Promise inside the component on every render is an infinite-loop trap. every render starts a new fetch, the new Promise suspends, the component remounts, the new render starts another fetch.
useOptimistic. Snappy Mutations Without Manual State
Optimistic updates used to require maintaining a parallel piece of state for "pending" items, manually rolling them back on error, and reconciling with the server response. useOptimistic collapses all of that into one hook.
"use client";
import { useOptimistic } from "react";
export function CommentList({ comments }: { comments: Comment[] }) {
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(state, newComment: Comment) => [...state, { ...newComment, pending: true }],
);
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
const tempComment = { id: crypto.randomUUID(), text };
addOptimistic(tempComment); // UI updates instantly
await postComment({ text }); // network in background
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" />
<button type="submit">Post</button>
</form>
</>
);
}
When the Server Action completes and re-renders the parent with fresh data, the optimistic state is automatically discarded. No manual reconciliation. No "loading" toggles. The UI is fast and correct.
Gotcha: useOptimistic only works inside a Client Component, and only with state that's controlled by a transition or form action. It doesn't replace your data layer. it sits on top of it.
useActionState. Forms, Pending States, and Errors
useActionState is the form-handling hook React 19 designed around Server Actions. It manages the action's pending state, captures its return value, and gives you typed error handling without external state libraries.
"use client";
import { useActionState } from "react";
import { createPost } from "./actions";
const initialState = { error: null as string | null, post: null as Post | null };
export function NewPostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" required />
<textarea name="body" required />
{state.error && <p className="error">{state.error}</p>}
<button type="submit" disabled={pending}>
{pending ? "Publishing..." : "Publish"}
</button>
{state.post && <p>Published: {state.post.title}</p>}
</form>
);
}
// actions.ts
"use server";
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title") as string;
if (!title) return { error: "Title required", post: null };
const post = await db.posts.create({ data: { title } });
return { error: null, post };
}
The action receives the previous state as its first argument. Whatever it returns becomes the new state. The hook gives you pending for free.
This pattern eliminates the entire genre of "set isSubmitting to true, try/catch, set error state, set isSubmitting to false" boilerplate.
Combining All Three
The full production pattern for a form that creates an item, shows it optimistically, and gracefully handles errors:
"use client";
import { useOptimistic, useActionState } from "react";
import { createTodo } from "./actions";
export function TodoApp({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, { ...newTodo, pending: true }],
);
const [state, formAction, pending] = useActionState(
async (_prev: any, formData: FormData) => {
const text = formData.get("text") as string;
addOptimistic({ id: crypto.randomUUID(), text });
const result = await createTodo(formData);
return result;
},
{ error: null },
);
return (
<>
<ul>{optimisticTodos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
<form action={formAction}>
<input name="text" required />
<button disabled={pending}>Add</button>
{state.error && <p>{state.error}</p>}
</form>
</>
);
}
Six lines of state management, optimistic UI, pending tracking, and error handling. total. This used to be 80+ lines spread across a Redux slice, an RTK Query mutation, and a form library.
Migration Advice
If you're on React 18, two practical steps:
- Adopt
useActionStatefirst. It's the lowest-risk change. it works with regularasyncfunctions, not just Server Actions, and it eliminates the most common form boilerplate. - Move parallel data fetching to
use()next. Refactor sequentialuseEffectchains into eager Promises +Suspense. The latency wins are immediate. - Add
useOptimisticlast, only where you measure that the perceived latency matters. Not every action needs an optimistic update. sometimes a spinner is fine.
Skip the temptation to rewrite everything at once. These hooks compose with React 18 patterns; you can adopt them route by route.