React 19 Features Your Next.js Application Should Use
React 19 shipped significant changes to how forms, actions, and async state work. Next.js 16 is built on it — here's what's new and how to use it effectively.
The React 19 Mental Model Shift
React 19 formalizes patterns that were previously handled by third-party libraries (React Query, Formik, Zustand for async state). The core addition: first-class support for async transitions and form actions.
The mental model: instead of managing loading and error state manually with useState, React 19 tracks async state transitions automatically. This reduces boilerplate and makes async interactions more predictable.
useActionState: Forms Without the Ceremony
useActionState is the new hook for form actions. It replaces the pattern of managing separate loading, error, and data state variables:
'use client';
import { useActionState } from 'react';
import { createProject } from '@/lib/actions/project';
export function CreateProjectForm() {
const [state, action, isPending] = useActionState(createProject, null);
return (
<form action={action}>
<input name="name" placeholder="Project name" />
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
{state?.fieldErrors?.name && (
<p className="text-red-500 text-sm">{state.fieldErrors.name}</p>
)}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Project'}
</button>
</form>
);
}
The form works progressively — if JavaScript hasn't loaded, the native form submission still works (as long as the Server Action handles FormData). With JavaScript, it's enhanced with loading state.
useOptimistic: Instant UI Updates
useOptimistic provides a temporary optimistic state while an async operation is in progress:
'use client';
import { useOptimistic } from 'react';
import { deleteProject } from '@/lib/actions/project';
export function ProjectList({ projects }: { projects: Project[] }) {
const [optimisticProjects, removeOptimistic] = useOptimistic(
projects,
(current, idToRemove: string) => current.filter(p => p.id !== idToRemove)
);
async function handleDelete(id: string) {
removeOptimistic(id); // Immediate UI update
await deleteProject(id); // Actual operation
// On success: the server re-renders with the real data
// On failure: React rolls back to the original state
}
return (
<ul>
{optimisticProjects.map(project => (
<li key={project.id}>
{project.name}
<button onClick={() => handleDelete(project.id)}>Delete</button>
</li>
))}
</ul>
);
}
The rollback on failure is automatic — useOptimistic tracks the original value and restores it if the async operation throws.
use(): Reading Promises in Render
The use() hook can unwrap a Promise during render. Unlike await in Server Components, use() works in Client Components and integrates with Suspense:
'use client';
import { use, Suspense } from 'react';
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until promise resolves
return <div>{user.name}</div>;
}
// Parent wraps with Suspense
export function Dashboard({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
More practically, use() can read context conditionally (unlike useContext, which must be called at the top level — though this distinction rarely matters in practice).
useTransition: Non-Urgent Updates
useTransition marks state updates as non-urgent, letting React keep the UI responsive while processing:
'use client';
import { useState, useTransition } from 'react';
export function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const newQuery = e.target.value;
setQuery(newQuery); // Update input immediately
startTransition(() => {
// Filtering can be deferred — input stays responsive
setFilteredItems(items.filter(item =>
item.name.toLowerCase().includes(newQuery.toLowerCase())
));
});
}
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
<div className={isPending ? 'opacity-60' : ''}>
{filteredItems.map(item => <ItemRow key={item.id} item={item} />)}
</div>
</div>
);
}
The input updates instantly. The filtered list updates in a transition — if the user types faster than the filter can compute, React interrupts the stale computation and starts fresh.
Form Actions Without useActionState
For simple forms that don't need client-side error display, forms can call Server Actions directly as their action:
// Server Action
'use server';
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get('email') as string;
await addSubscriber(email);
redirect('/thank-you');
}
// Client form — no client state needed
export default function NewsletterForm() {
return (
<form action={subscribeToNewsletter}>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
Progressive enhancement: no JavaScript needed for the form to work.
Improved Error Handling: onCaughtError and onUncaughtError
React 19 adds new root-level error callbacks for better error reporting:
// In your React root setup (not standard Next.js setup, but useful context)
const root = createRoot(container, {
onCaughtError(error, errorInfo) {
// Error caught by an error boundary
reportToBugTracker(error, { componentStack: errorInfo.componentStack });
},
onUncaughtError(error, errorInfo) {
// Error NOT caught by any error boundary
// These would crash the app in React 18
reportToBugTracker(error, { fatal: true });
},
});
In Next.js, use global-error.tsx for the equivalent; these callbacks are more relevant for non-Next.js React applications.
The Practical Summary
For a Next.js App Router application, the React 19 features you'll use most:
| Feature | Replaces | Use case |
|---|---|---|
useActionState | Manual loading/error state + Server Actions | All form submissions |
useOptimistic | Manual optimistic update + rollback logic | Mutations that should feel instant |
useTransition | Debouncing, manual interruption handling | Search, filtering, non-urgent updates |
use() with Promise | useEffect + fetch | Rarely needed — Server Components cover most cases |
React 19 closes the gap between "what React provides" and "what you need third-party libraries for" in the common case. The remaining cases for React Query and similar libraries: advanced caching strategies, background refetching, and complex server state synchronization.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources