The most consequential thing in React right now is not a new hook. It is that the React Compiler reached stable 1.0, which means the memoization work you have been doing by hand for years is largely obsolete.
This guide covers what that actually changes, plus Actions and the React 19.2 additions. Versions at the time of writing: React 19.2 with React Compiler 1.0.
The compiler, and what it removes
React re-renders a component when its state or props change, and re-runs everything inside. The traditional fix was to wrap things by hand:
// The old way — and there is a lot of it in most codebases
const visibleItems = useMemo(
() => items.filter(i => i.status === filter),
[items, filter]
)
const handleSelect = useCallback(
(id) => setSelected(id),
[]
)
const Row = memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>
})
The compiler does this automatically, at build time, by analysing what actually depends on what:
// With the compiler enabled — same performance, no ceremony
const visibleItems = items.filter(i => i.status === filter)
function handleSelect(id) {
setSelected(id)
}
function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>
}
It also does something you cannot do manually: memoize conditionally, and at a finer granularity than whole values. Hooks cannot be called conditionally, so hand-written memoization is always coarser than optimal.
Enabling it
npm i -D babel-plugin-react-compiler eslint-plugin-react-hooks
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: { plugins: [['babel-plugin-react-compiler', {}]] },
}),
],
})
Run the lint rules before you enable it. The compiler only optimises components it can prove follow the rules of React, and the linter tells you which ones do not — mutating props, reading refs during render, conditional hooks. Those components are skipped silently rather than breaking, so without the linter you get partial coverage and no explanation.
Should you delete your existing useMemo calls?
Not urgently, and not all at once.
Existing useMemo and useCallback calls keep working — the compiler does not conflict with them. So there is no forced migration.
The practical approach: stop writing new ones, and remove old ones when you are already editing that file. A mass deletion across the codebase is a large diff with real regression risk and no user-visible benefit, which is a poor trade.
The exception worth keeping: useMemo around something genuinely expensive that is not about render identity — a heavy computation you want cached across renders for its own sake. That is a different concern from memoizing for referential stability.
Actions
Actions handle the pattern every form has: pending state, errors, and optimistic updates. Previously that was four useState calls and a try/catch.
import { useActionState } from 'react'
function UpdateProfile({ user }) {
const [state, formAction, isPending] = useActionState(
async (previousState, formData) => {
try {
await saveProfile({ name: formData.get('name') })
return { ok: true, error: null }
} catch (e) {
return { ok: false, error: e.message }
}
},
{ ok: false, error: null }
)
return (
<form action={formAction}>
<input name="name" defaultValue={user.name} />
<button disabled={isPending}>
{isPending ? 'Saving…' : 'Save'}
</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
)
}
isPending comes free, and because this is a real form action, submission works before hydration completes.
Optimistic updates
import { useOptimistic } from 'react'
function Comments({ comments, addComment }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, newComment) => [...state, { ...newComment, pending: true }]
)
async function action(formData) {
const text = formData.get('text')
addOptimistic({ id: 'temp', text }) // shows immediately
await addComment(text) // reverts automatically on failure
}
return (
<>
{optimistic.map(c => (
<p key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</p>
))}
<form action={action}>
<input name="text" />
<button>Post</button>
</form>
</>
)
}
The automatic revert is the valuable part. Hand-rolled optimistic updates usually get the happy path right and the rollback wrong.
What React 19.2 added
| Feature | What it is for |
|---|---|
<Activity> | Hide a subtree while preserving its state — tabs, wizards, back navigation |
useEffectEvent | Read the latest value in an effect without adding it to the dependency array |
cacheSignal | Cancel work when a cached request is no longer needed |
| Performance Tracks | React-specific timings in browser devtools |
| Partial pre-rendering | Serve a static shell, stream the rest |
| Batched Suspense reveals | Fewer layout jumps during server rendering |
useEffectEvent is the one that solves a genuinely common annoyance — the effect that should not re-run, but has to list a value it reads:
import { useEffectEvent } from 'react'
function ChatRoom({ roomId, theme }) {
// Reads the current theme without making the effect depend on it
const onConnected = useEffectEvent(() => {
showNotification('Connected', theme)
})
useEffect(() => {
const conn = createConnection(roomId)
conn.on('connected', onConnected)
conn.connect()
return () => conn.disconnect()
}, [roomId]) // theme is not here — and does not need to be
}
Before this, changing the theme reconnected the chat room. The usual workarounds were a ref or lying to the dependency array; both were worse.
What the compiler cannot fix
Worth being clear, because "automatic optimization" invites the wrong expectation:
- Rendering too much at once. Ten thousand rows are slow whether or not they are memoized. Virtualise the list.
- Genuinely expensive computation. The compiler avoids repeating work; it does not make the work cheaper.
- Network waterfalls. Sequential requests are an architecture problem, not a rendering one.
- Oversized bundles. Nothing about memoization reduces what you ship.
- Components that break the rules of React. These are skipped entirely — which is exactly why you run the linter.
The compiler removes a class of tedious work. It does not remove the need to understand why something is slow.
A sensible order
- Add the ESLint plugin and fix what it reports. Useful on its own.
- Enable the compiler and verify in React DevTools that components show as optimised.
- Stop writing new
useMemoanduseCallback. - Adopt Actions for forms as you touch them.
- Remove old memoization opportunistically, never as a sweep.
Frequently asked questions
Is React Compiler production ready?
Yes. It reached stable 1.0 in October 2025 after an extended beta and heavy use in production at Meta. It works with React 19 and React Native.
Do I still need useMemo and useCallback in React 19?
Rarely, once the compiler is enabled — it memoizes automatically, and more precisely than hooks allow, because it can memoize conditionally. Keep useMemo only for genuinely expensive computation you want cached for its own sake.
Should I remove all my existing useMemo calls?
Not in one sweep. They still work and do not conflict with the compiler. Stop adding new ones and remove old ones when you are editing that file anyway — a mass deletion is a large diff with regression risk and no visible benefit.
Why is the compiler skipping some of my components?
It only optimises components it can prove follow the rules of React. Mutating props, reading refs during render or calling hooks conditionally will cause it to skip a component silently. The ESLint plugin tells you which ones and why.
What is useActionState?
A hook that wraps an async form handler and gives you the result, a form action and a pending flag in one call, replacing the usual cluster of loading and error state. Because it attaches to a real form action, it also works before hydration.
What is useEffectEvent for?
Reading the latest value of something inside an effect without adding it to the dependency array. It solves the case where an effect re-runs for a value it merely reads — such as reconnecting a socket because a theme changed.
What is the Activity component?
A way to hide part of the tree while preserving its state, so a tab or wizard step keeps its scroll position and form input when you return to it, rather than being unmounted and rebuilt.
Does the compiler make my app faster automatically?
It removes unnecessary re-renders, which helps when that is your bottleneck. It does nothing for oversized lists, expensive computation, network waterfalls or large bundles — diagnose the actual cause before expecting it to help.
Comments (0)
Leave a Comment
No comments yet. Be the first to share your thoughts!