React 19 Features Every Developer Should Know
·PkgPulse Team
TL;DR
React 19 is the most impactful React release since hooks. The Actions API simplifies async state mutations by 80% — no more hand-rolling loading/error/success states. The use() hook enables promise and context consumption anywhere in a component. The React Compiler (formerly React Forget) eliminates most useMemo/useCallback needs. These aren't incremental improvements — they change how you write React day-to-day. Most teams on Next.js 15 are already using React 19 features without realizing it.
Key Takeaways
- Actions API: async transitions that auto-handle pending/error states — replaces manual loading state management
use()hook: read promises and context anywhere, with Suspense integration- React Compiler: automatic memoization — delete most of your
useMemo/useCallback useOptimistic: optimistic updates with automatic rollback on errorrefas prop: no moreforwardRefwrapper — pass ref directly as a prop
Actions API: The Biggest Ergonomics Win
// Before React 19 — manual async state management:
function SubmitButton() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPending(true);
setError(null);
try {
await submitForm(new FormData(e.target as HTMLFormElement));
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <p className="text-red-500">{error}</p>}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
// React 19 — Actions + useActionState:
function SubmitButton() {
const [state, submitAction, isPending] = useActionState(
async (prevState: { error: string | null }, formData: FormData) => {
try {
await submitForm(formData);
return { error: null };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Failed' };
}
},
{ error: null }
);
return (
<form action={submitAction}>
{state.error && <p className="text-red-500">{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
// The gains:
// → No manual isPending management
// → No try/catch noise in the component
// → Works with <form action={...}> — progressive enhancement
// → Works with Server Actions in Next.js (same API)
useOptimistic: Instant UI Without Race Conditions
// Optimistic updates — classic pattern (pre-React 19):
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodos] = useState(todos);
const handleToggle = async (id: string) => {
// Optimistically update UI
setOptimisticTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
try {
await toggleTodo(id);
} catch {
// Revert on error — manually
setOptimisticTodos(todos);
}
};
// Problem: managing sync between optimisticTodos and real todos is error-prone
}
// React 19 — useOptimistic:
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], optimisticValue: { id: string; done: boolean }) =>
state.map(t =>
t.id === optimisticValue.id ? { ...t, done: optimisticValue.done } : t
)
);
const handleToggle = async (id: string, currentDone: boolean) => {
// Optimistically update immediately
addOptimistic({ id, done: !currentDone });
// Actually update on server
await toggleTodo(id);
// If toggleTodo throws: React automatically reverts to the real todos
};
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.done ? 'opacity-50' : ''}>
{todo.text}
<button onClick={() => handleToggle(todo.id, todo.done)}>
Toggle
</button>
</li>
))}
</ul>
);
}
// Automatic rollback on error — no manual revert code needed
The use() Hook: Flexible Async and Context
// React 19 — use() hook reads promises and context, anywhere:
// 1. Reading a promise with Suspense:
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() suspends if the promise is pending
// Works in regular function components — not just at the top level
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
// Parent wraps in Suspense:
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>
// 2. Conditional context reading (impossible with useContext):
function ThemeButton({ showTheme }: { showTheme: boolean }) {
// useContext must be called unconditionally
// use() can be called conditionally:
if (showTheme) {
const theme = use(ThemeContext); // ✅ Valid in React 19
return <button style={{ background: theme.primary }}>Themed</button>;
}
return <button>Plain</button>;
}
// 3. Combining with Server Components (Next.js pattern):
// In a Server Component:
async function Page() {
const userPromise = fetchUser(); // Starts fetching immediately
const postsPromise = fetchPosts(); // Starts fetching immediately
return (
<Suspense fallback={<Skeleton />}>
{/* Pass promises to Client Components — they fetch in parallel */}
<UserProfile userPromise={userPromise} />
<PostList postsPromise={postsPromise} />
</Suspense>
);
}
// Both fetches start at the same time, stream as they resolve
React Compiler: Delete Your useMemo
// Before React Compiler — manual memoization required:
function ExpensiveList({ items, filter, onSelect }: Props) {
// Need useMemo to prevent recomputing on unrelated re-renders
const filteredItems = useMemo(
() => items.filter(item => item.category === filter),
[items, filter]
);
// Need useCallback to prevent child re-renders
const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);
// Need to wrap child in React.memo to prevent unnecessary renders
return (
<ul>
{filteredItems.map(item => (
<MemoizedItem key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
// 3 separate "performance hooks" for one component
// After React Compiler — compiler handles this automatically:
function ExpensiveList({ items, filter, onSelect }: Props) {
// No useMemo, no useCallback, no React.memo needed
const filteredItems = items.filter(item => item.category === filter);
const handleSelect = (id: string) => onSelect(id);
return (
<ul>
{filteredItems.map(item => (
<Item key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
// The compiler understands when values change and skips re-renders automatically
// How to enable (Next.js 15 — it's opt-in):
// next.config.ts:
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
// Standalone React + Babel:
// babel.config.json:
{
"plugins": ["babel-plugin-react-compiler"]
}
// Current status (2026):
// → Stable, shipping in production at Meta
// → Next.js 15: opt-in experimental flag
// → Vite plugin available
// → Some edge cases (improper use of refs, mutation of props) may not compile
Smaller But Impactful Changes
// 1. ref as prop (no more forwardRef):
// Before React 19:
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input {...props} ref={ref} />
));
// React 19 — ref is just a prop:
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input {...props} ref={ref} />;
}
// Or with the new type shorthand:
function Input({ ref, ...props }: React.ComponentProps<'input'>) {
return <input {...props} ref={ref} />;
}
// forwardRef still works for backward compatibility
// 2. Document metadata in components (no more react-helmet):
function ProductPage({ product }: { product: Product }) {
return (
<>
{/* React 19: these render in <head>, not in the div */}
<title>{product.name} | Store</title>
<meta name="description" content={product.description} />
<link rel="canonical" href={`https://store.com/products/${product.id}`} />
<main>...</main>
</>
);
}
// Works in both Server Components and Client Components
// Deduplication: multiple <title> in the tree? Last one wins.
// 3. Async scripts with deduplication:
function Analytics() {
return (
<script
async
src="https://analytics.example.com/script.js"
data-website-id="abc123"
/>
);
}
// React 19 deduplicates: even if rendered 100 times, script loads once
// 4. Stylesheet loading with priority:
<link rel="stylesheet" href="/styles/component.css" precedence="default" />
// React manages insertion order based on precedence
// Solves the CSS-in-JS stylesheet ordering problem for Server Components
Track React and related package health at PkgPulse.
See the live comparison
View react vs. vue on PkgPulse →