React 19 New Features — Complete Guide for Next.js Developers in 2026
Advertisement
Introduction
Why This Matters
React 19 shipped with Next.js 15 as the default React version. It introduces a set of primitives that make form handling, async state, and optimistic UI dramatically simpler. Previously, you needed useState, useEffect, and manual loading/error tracking for every form. React 19 collapses this into a single hook in many cases.
The React Compiler (formerly React Forget) is also now stable — it automatically adds memoization where needed, eliminating most useMemo and useCallback calls. For Next.js developers, this means less boilerplate and better performance by default.
useActionState — The New Standard for Forms
useActionState (previously useFormState from react-dom) manages form state through Server Actions. It replaces the useState + useEffect + manual error handling pattern:
// app/actions.ts
'use server'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
const schema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(10, 'Content must be at least 10 characters'),
})
type FormState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
export async function createPost(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
}
const result = schema.safeParse(raw)
if (!result.success) {
return {
success: false,
message: 'Validation failed',
errors: result.error.flatten().fieldErrors,
}
}
await prisma.post.create({ data: result.data })
revalidatePath('/blog')
return { success: true, message: 'Post created successfully' }
}// app/create/page.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
const initialState = { success: false, message: '' }
export default function CreatePostPage() {
const [state, formAction, isPending] = useActionState(createPost, initialState)
return (
<form action={formAction} className="space-y-4 max-w-lg">
{state.message && (
<p className={state.success ? 'text-green-600' : 'text-red-600'}>
{state.message}
</p>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium">Title</label>
<input
id="title"
name="title"
className="mt-1 w-full border rounded p-2"
/>
{state.errors?.title && (
<p className="text-red-600 text-sm mt-1">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">Content</label>
<textarea
id="content"
name="content"
rows={6}
className="mt-1 w-full border rounded p-2"
/>
{state.errors?.content && (
<p className="text-red-600 text-sm mt-1">{state.errors.content[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}useActionState returns [state, formAction, isPending]. The third element isPending replaces the need for useTransition in most form scenarios.
useFormStatus — Pending State Anywhere in the Form
useFormStatus reads the pending state of the nearest parent <form>. It must be used in a child component, not in the form component itself:
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ label = 'Submit' }: { label?: string }) {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50 flex items-center gap-2"
>
{pending && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{pending ? 'Processing...' : label}
</button>
)
}Use SubmitButton inside any form that uses a Server Action as its action:
<form action={createPost}>
<input name="title" />
<SubmitButton label="Publish Post" />
</form>useOptimistic — Instant UI with Rollback
useOptimistic shows an optimistic (assumed-successful) UI update immediately, then reverts if the server operation fails:
// app/todos/page.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/app/actions'
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [, startTransition] = useTransition()
const [optimisticTodos, updateOptimistic] = useOptimistic(
todos,
(state: Todo[], updatedId: string) =>
state.map((t) =>
t.id === updatedId ? { ...t, completed: !t.completed } : t
)
)
function handleToggle(id: string) {
startTransition(async () => {
updateOptimistic(id) // immediate UI update
await toggleTodo(id) // actual server call
})
}
return (
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-3 cursor-pointer"
onClick={() => handleToggle(todo.id)}
>
<span className={`w-4 h-4 border rounded ${todo.completed ? 'bg-blue-600' : ''}`} />
<span className={todo.completed ? 'line-through text-gray-400' : ''}>
{todo.text}
</span>
</li>
))}
</ul>
)
}The UI toggles instantly. If toggleTodo throws, React reverts to the original state automatically.
useTransition with Async Functions
React 19 allows useTransition to wrap async functions, making it the right tool for async operations that should not block the UI:
'use client'
import { useTransition, useState } from 'react'
export function SearchBox() {
const [results, setResults] = useState<string[]>([])
const [isPending, startTransition] = useTransition()
function handleSearch(query: string) {
startTransition(async () => {
const data = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
setResults(data)
})
}
return (
<div>
<input
type="search"
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
className="border p-2 rounded w-full"
/>
{isPending && <p className="text-gray-400 mt-2">Searching...</p>}
<ul className="mt-4 space-y-1">
{results.map((r) => <li key={r}>{r}</li>)}
</ul>
</div>
)
}The React Compiler
The React Compiler analyzes your component code and automatically adds memoization where appropriate. You no longer need useMemo, useCallback, or React.memo in most cases:
// Before React Compiler — manual memoization
function ProductList({ products, filter }: Props) {
const filtered = useMemo(
() => products.filter((p) => p.category === filter),
[products, filter]
)
const handleClick = useCallback((id: string) => {
console.log('Clicked:', id)
}, [])
return filtered.map((p) => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
))
}
// After React Compiler — the compiler adds memoization automatically
function ProductList({ products, filter }: Props) {
const filtered = products.filter((p) => p.category === filter)
function handleClick(id: string) {
console.log('Clicked:', id)
}
return filtered.map((p) => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
))
}Enable the compiler in Next.js:
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
}Improved Ref Handling
React 19 allows function components to accept ref as a prop without forwardRef:
// React 19 — ref as a prop, no forwardRef needed
function Input({
label,
ref,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { label: string; ref?: React.Ref<HTMLInputElement> }) {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
<input ref={ref} className="mt-1 border rounded p-2 w-full" {...props} />
</div>
)
}
// Usage
function Form() {
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<form>
<Input label="Email" type="email" ref={inputRef} />
</form>
)
}Common Mistakes
- Using
useFormStatefromreact-dom— it is renamed touseActionStatein React 19 and imported fromreact - Putting
useFormStatusin the same component as the<form>— it must be in a child component - Not wrapping
updateOptimisticinstartTransition— this is required for optimistic updates to work correctly - Enabling the React Compiler without testing — run it on one route first; some patterns with external mutable objects may need adjustment
- Forgetting that
useActionStaterequires the action function to accept(prevState, formData)as arguments
Best Practices
- Use
useActionStatefor all form submissions that call Server Actions — it handles pending, error, and success state in one hook - Extract
SubmitButtonas a reusable component that readsuseFormStatusso any form gets loading state for free - Use
useOptimisticfor toggle and like actions where the expected outcome is almost always success - Validate form data with Zod inside the Server Action and return structured field errors to display inline
- Enable the React Compiler incrementally — set
compilationMode: 'annotation'to opt in per file
Key Takeaways
useActionState(fromreact, notreact-dom) replacesuseState+ manual loading/error tracking for Server Action formsuseFormStatusmust be used in a child component of the form — it cannot read from the same component that renders the formuseOptimisticupdates the UI immediately and auto-reverts if the async operation fails- React 19 allows
refas a direct prop on function components —forwardRefis no longer required - The React Compiler is now stable and can be enabled in Next.js with
experimental.reactCompiler: true useTransitionin React 19 accepts async functions, making it the standard for async state transitionsuseActionStatethird return valueisPendingreplaces the separateuseTransition+useStateloading pattern- All React 19 hooks (
useActionState,useOptimistic) work with both Client Components and Server Components via Server Actions
Advertisement