React 19 Hooks You Should Be Using in Next.js
useOptimistic, use(), useFormStatus and useActionState, with the placement rules and argument order that are easy to get wrong.
React 19 shipped hooks built around Server Actions. These are the ones I reach for in Next.js, and where each has tripped me up.
useOptimistic: Instant UI Updates
useOptimistic holds a temporary UI state while an async operation is in flight. When it settles, React renders from the real state again.
"use client";
import { useOptimistic } from "react";
import { toggleLike } from "@/app/actions";
export function LikeButton({ postId, initialLikes, initialLiked }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(initialLiked);
async function handleClick() {
// Immediately update the UI
setOptimisticLiked(!optimisticLiked);
// Then perform the real action
await toggleLike(postId);
}
return (
<button onClick={handleClick}>
{optimisticLiked ? "❤️" : "🤍"} {initialLikes + (optimisticLiked ? 1 : 0)}
</button>
);
}The heart fills on the click, before the request finishes. If the action fails, the button returns to initialLiked. I hand-rolled that once.
use(): Consume Promises and Context
use() unwraps a Promise or Context value inside a component. It isn't a hook in the rules-of-hooks sense, so it can be called inside a condition or a loop.
Resolving a Promise
"use client";
import { use, Suspense } from "react";
function UserName({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return <span>{user.name}</span>;
}
// The promise is created in a Server Component and passed down
export default function Page() {
const userPromise = fetchUser(); // Returns Promise<User>
return (
<Suspense fallback="Loading...">
<UserName userPromise={userPromise} />
</Suspense>
);
}It doesn't fetch anything, which was my first wrong assumption. The promise has to exist already, usually created on the server and handed down. Without a Suspense boundary, the nearest parent catches the wait.
Reading Context
import { use } from "react";
import { ThemeContext } from "@/context/theme";
function ThemedButton({ children }) {
const theme = use(ThemeContext); // Works inside conditionals
return (
<button style={{ background: theme.primary }}>
{children}
</button>
);
}useFormStatus: Access Parent Form State
useFormStatus reads the pending state of the nearest parent <form>.
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
);
}
// Use it anywhere inside a form — no props needed
function ContactForm() {
return (
<form action={submitContact}>
<input name="email" type="email" />
<input name="message" />
<SubmitButton /> {/* Knows about parent form's pending state */}
</form>
);
}It has to be a child of the form. Call it in the component that renders <form> and pending stays false; that cost me an afternoon. It also lives in react-dom.
useActionState: Form Actions With State
useActionState (it was useFormState before the 19 release) keeps the value returned by a Server Action across submissions:
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
type State = { error?: string; success?: boolean };
export function NewPostForm() {
const [state, formAction, isPending] = useActionState<State, FormData>(
createPost,
{}
);
return (
<form action={formAction}>
<input name="title" required />
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Post created!</p>}
<button disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}The Server Action returns State:
// app/actions.ts
"use server";
export async function createPost(prevState: State, formData: FormData): Promise<State> {
const title = formData.get("title") as string;
if (!title) return { error: "Title is required" };
await db.post.create({ data: { title } });
revalidatePath("/posts");
return { success: true };
}The argument order is easy to get backwards: previous state first, then the form data. Return the same shape on every path.
useTransition: Non-Blocking State Updates
useTransition marks updates as low priority so input stays responsive during async work. React 19 lets startTransition take an async function directly.
"use client";
import { useTransition, useState } from "react";
export function SearchInput() {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleSearch(query: string) {
startTransition(async () => {
const data = await searchPosts(query);
setResults(data); // Non-blocking — input stays responsive
});
}
return (
<div>
<input onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultList items={results} />
</div>
);
}Typing keeps up while the request runs. Two responses can still land out of order, though. I've had to track a request id for that more than once.
When to Use Which
| Hook | Use case |
|---|---|
useOptimistic | Show immediate feedback before server confirms |
use() | Unwrap promises passed from server; read context conditionally |
useFormStatus | Disable/style submit buttons based on form pending state |
useActionState | Manage form submission state and error/success messages |
useTransition | Keep UI responsive during expensive updates |
They're designed to work with Server Actions, so one in isolation rarely helps. Start with useOptimistic and useFormStatus if you have hand-rolled pending state to delete.