What's New in React 19 — The Changes That Matter
React 19 key changes worth knowing: Actions, useActionState, use(), ref improvements, and native metadata support.

React 19 has been out for a while now, but plenty of projects are still on 18. The upgrade path isn't always obvious, and the "what's actually different" question doesn't get a straight answer from most release notes. So here's a breakdown focused on what changes your day-to-day code — not internal architecture shifts, not compiler theory, just the stuff you'll feel when writing components.
Actions — Form Handling Gets a Real Upgrade
This is the biggest change. How you handle form submissions and data mutations is fundamentally different now.
The old way required a lot of boilerplate:
// React 18 approach
function LoginForm() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setIsPending(true);
setError(null);
try {
await login(formData);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
}
return <form onSubmit={handleSubmit}>...</form>;
}
Loading state, error state, try-catch... the same pattern over and over. React 19 cleans this up with the action prop:
// React 19 approach
function LoginForm() {
async function loginAction(formData: FormData) {
"use server"; // Runs on the server (Next.js, etc.)
await login(formData);
}
return <form action={loginAction}>...</form>;
}
Pass an async function to <form action> and React manages the pending state automatically. Less code, same result.
useActionState — The Companion Hook for Forms
This hook pairs with Actions to manage form state: results, errors, loading.
import { useActionState } from "react";
function ContactForm() {
const [state, submitAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
const result = await sendMessage(formData);
if (!result.success) return { error: result.message };
return { success: true };
},
null // initial state
);
return (
<form action={submitAction}>
<input name="message" />
<button disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Sent!</p>}
</form>
);
}
isPending is managed for you — no more useState for loading flags. Error handling flows through the return value instead of try-catch blocks.
useOptimistic — Built-in Optimistic Updates
You know the pattern: user clicks "Like," you update the UI immediately without waiting for the server. Previously you had to wire this up yourself. Now there's a hook for it.
import { useOptimistic } from "react";
function LikeButton({ count, onLike }) {
const [optimisticCount, addOptimistic] = useOptimistic(
count,
(current, increment: number) => current + increment
);
return (
<form action={async () => {
addOptimistic(1); // Update UI immediately
await onLike(); // Server request runs in background
}}>
<button>Like {optimisticCount}</button>
</form>
);
}
If the server request fails, it automatically rolls back to the previous state. No manual rollback logic needed.
use() — Reading Promises and Context Directly
The new use() API lets you read a Promise directly inside a component.
import { use } from "react";
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspense handles loading
return <h1>{user.name}</h1>;
}
This replaces the useEffect + useState pattern for async data loading. Combined with Suspense, loading states are handled automatically.
Unlike other hooks, use() can be called conditionally — inside if statements. That's because use() is technically an API, not a hook, so it's exempt from the rules of hooks.
It also works with Context:
const theme = use(ThemeContext); // replaces useContext(ThemeContext)
useContext isn't going away, but use() is handy when you need conditional reads.
Refs Got Simpler
Until React 18, passing a ref to a function component meant wrapping it with forwardRef. The boilerplate was annoying enough that people sometimes skipped ref forwarding entirely.
// React 18 — forwardRef required
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />;
});
In React 19, ref is just a regular prop:
// React 19 — just a prop
function Input({ ref, ...props }: InputProps & { ref?: Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
No wrapper needed. Cleaner code, better type inference. forwardRef still works but will be deprecated eventually.
Native Metadata Support
Render <title>, <meta>, and <link> tags directly in your components and React automatically hoists them to <head>.
function BlogPost({ post }) {
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.description} />
<article>{post.content}</article>
</>
);
}
Previously you needed react-helmet or Next.js's Head component. With native support, that's one less dependency.
Stylesheet loading order management (<link rel="stylesheet" precedence="high">) was also added, preventing CSS order conflicts when loading styles per component.
React Compiler — Automatic Memoization
The React Compiler (formerly known as React Forget) is a separate but closely related project worth mentioning here. The compiler analyzes your components at build time and automatically inserts useMemo, useCallback, and React.memo where they're actually needed.
If you've ever spent time wrapping every handler in useCallback or every derived value in useMemo "just in case," this is directly aimed at that pain. The compiler figures out which values are stable and which actually change between renders, then optimizes accordingly.
// Before: manual memoization everywhere
function ProductList({ items, onSelect }) {
const sorted = useMemo(() => items.sort(byPrice), [items]);
const handleClick = useCallback((id) => onSelect(id), [onSelect]);
return sorted.map(item => (
<ProductCard key={item.id} item={item} onClick={handleClick} />
));
}
// After: just write normal code, compiler handles it
function ProductList({ items, onSelect }) {
const sorted = items.sort(byPrice);
const handleClick = (id) => onSelect(id);
return sorted.map(item => (
<ProductCard key={item.id} item={item} onClick={handleClick} />
));
}
The second version is what you'd naturally write without worrying about performance. The compiler makes it perform the same as the manually optimized version. There's a catch though — it requires your components to follow the Rules of React (pure rendering, no side effects during render). If your codebase has components that read or write external state during render without hooks, the compiler might produce unexpected results or skip optimizing those components entirely.
The compiler is opt-in and currently works best with Babel-based setups. You can adopt it incrementally, file by file, using directives. It's not a React 19 requirement — your code works fine without it — but it removes an entire category of performance busywork.
Other Practical Changes
Ref callback cleanup — Ref callbacks can now return a cleanup function. Makes it easier to run teardown logic when a DOM element unmounts.
Better error reporting — onCaughtError and onUncaughtError callbacks give you finer control over error boundary behavior.
<Context> as a provider — You can use <Context> directly instead of <Context.Provider>. Small change, one less level of nesting.
Migrating from React 18 — What Actually Breaks
For new projects, just start with React 19. No reason not to.
For existing projects, the migration isn't a simple version bump. Here are the breaking changes that trip people up most often:
ReactDOM.render and ReactDOM.hydrate are gone. If you somehow still had these in your codebase instead of createRoot and hydrateRoot, they won't just warn anymore — they'll throw. This one's usually easy to fix since the migration was recommended back in React 18.
Runtime propTypes checking is removed. React no longer validates propTypes at runtime. If you relied on propTypes warnings in development, switch to TypeScript or keep propTypes only as documentation. Libraries that ship propTypes won't cause errors, but the checks simply won't run.
defaultProps on function components is deprecated. Use JavaScript default parameters instead. Class components still support defaultProps, but function components should move to destructuring defaults:
// Before
function Button({ size }) { ... }
Button.defaultProps = { size: 'medium' };
// After
function Button({ size = 'medium' }) { ... }
String refs are removed. This was deprecated ages ago, but if legacy code still uses ref="myInput", it breaks now. Switch to useRef or callback refs.
Some internal APIs moved. react-test-renderer/shallow was removed from the core. If your tests rely on shallow rendering, you'll need the react-shallow-renderer package. Also, act() now must be imported from react instead of react-dom/test-utils.
Library Compatibility — The Real Blocker
The biggest factor in whether you can migrate isn't your own code — it's your dependencies. Major UI libraries like Material UI, Chakra UI, Ant Design, and React Hook Form all needed updates for React 19 compatibility. Most have released compatible versions by now, but double-check before you start.
Run npm ls react to see which packages depend on React, then check each one's changelog or GitHub issues for React 19 support. Pay special attention to:
- Form libraries (react-hook-form, formik) — these interact heavily with React's internals
- UI component libraries — they often use
forwardRefpatterns that changed - State management — most (Zustand, Jotai, Redux Toolkit) updated quickly, but verify your specific version
- Testing libraries — React Testing Library v16+ supports React 19; earlier versions may not
If a critical dependency hasn't shipped React 19 support, that's your answer: wait. Forcing a migration with incompatible libraries creates subtle bugs that are painful to debug. React 18 is stable and will receive security patches. There's no rush.