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.
Nextcraft Engineering Team
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
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.