React 19 has been stable since December 2024, and by early 2026 it’s the baseline for new projects. But migrating an existing production app? That’s a different story.
Last month, I migrated our internal dashboard — roughly 50,000 lines of React, 120 components, TypeScript strict mode — from React 18 to React 19. The whole process took two days. One breaking change broke staging for 2 hours. But the end result was worth it: we deleted 200 lines of useMemo/useCallback, replaced our entire form state library with useActionState, and the app feels noticeably snappier.
Here’s exactly what happened — the good, the bad, and the “why didn’t we do this sooner.”
What Changed in React 19 (The Short Version)
If you haven’t been following along, here are the three things that actually matter for a migration:
The React Compiler — automatic memoization. You no longer need useMemo and useCallback for performance. The compiler figures it out at build time. This alone is worth the upgrade.
Actions (useActionState) — a new pattern for handling form submissions and async mutations. It gives you pending, error, and data states out of the box. No more hand-rolling loading spinners.
The use() hook — read promises and context anywhere in your component tree, not just at the top level. This replaces a lot of useEffect + useState patterns for data fetching.
Everything else — Server Components being stable, document metadata, stylesheets — is incremental. Important, but not migration blockers.
Step 1: The Upgrade (15 Minutes)
This part was shockingly easy:
npm install react@19 react-dom@19
That’s it. React 19 is a drop-in replacement for React 18. No peer dependency conflicts, no breaking package changes. Our package.json went from "react": "^18.3.1" to "react": "^19.0.0".
Then I ran the build. It compiled without errors. I was suspicious — it’s never this easy.
Step 2: The Breaking Change That Broke Staging
And then I deployed to staging.
Our app uses react-beautiful-dnd for drag-and-drop. React 19 removed ReactDOM.findDOMNode, and react-beautiful-dnd depends on it. The entire drag-and-drop section crashed with:
TypeError: ReactDOM.findDOMNode is not a function
The fix: Switch to @dnd-kit/core and @dnd-kit/sortable. It’s the modern replacement for react-beautiful-dnd, and it doesn’t use findDOMNode.
The migration took about 3 hours. The API is different but cleaner:
// Before: react-beautiful-dnd
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, i) => (
<Draggable key={item.id} draggableId={item.id} index={i}>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps}>
<Item {...item} />
</div>
)}
</Draggable>
))}
</div>
)}
</Droppable>
</DragDropContext>
// After: @dnd-kit
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={items}>
<div>
{items.map((item) => (
<SortableItem key={item.id} id={item.id}>
<Item {...item} />
</SortableItem>
))}
</div>
</SortableContext>
</DndContext>
Less nesting, less boilerplate, and it actually works with React 19.
“Always check your third-party dependencies before upgrading. Use
npm ls react-domto see what’s pulling in old APIs. If a library hasn’t been updated in 6+ months, assume it’ll break.”
Step 3: The React Compiler — The Best Part
This is where React 19 gets exciting. The React Compiler automatically memoizes your components at build time. No more useMemo, no more useCallback, no more “did I memoize this correctly?”
I installed it:
npm install babel-plugin-react-compiler
And added it to our Babel config. Then I ran a codemod that the React team provides:
npx react-compiler-healthcheck
The codemod found 200+ unnecessary useMemo and useCallback calls. I deleted all of them. The app got faster, not slower.
| Metric | Before (React 18) | After (React 19 + Compiler) |
|---|---|---|
| useMemo calls | 142 | 0 (all removed) |
| useCallback calls | 87 | 0 (all removed) |
| Bundle size | 340 KB | 335 KB (-5 KB) |
| Re-render count (dashboard) | 47 per interaction | 12 per interaction |
The re-render count dropped by 75%. That’s not a typo. The compiler is smarter than most developers at figuring out what needs to re-render.
Step 4: Replacing Our Form Library with useActionState
We were using react-hook-form + a custom mutation hook for our settings page. React 19’s useActionState replaced both:
// Before: react-hook-form + custom mutation
const { register, handleSubmit } = useForm<Settings>();
const { mutate, isPending, error } = useUpdateSettings();
const onSubmit = handleSubmit((data) => {
mutate(data);
});
// After: useActionState
async function updateSettings(prev: State, formData: FormData) {
const result = await api.updateSettings(formData);
return result;
}
const [state, formAction, isPending] = useActionState(updateSettings, null);
<form action={formAction}>
<input name="theme" defaultValue={state?.theme} />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
I’m not saying ditch react-hook-form entirely — it’s still great for complex forms with validation. But for simple settings pages and CRUD forms, useActionState is all you need.
Step 5: The use() Hook — Cleaning Up Data Fetching
We had this pattern everywhere:
// Before: useEffect + useState for data fetching
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}
With use(), this becomes:
// After: use() hook
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
// Parent component handles Suspense
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={fetchUser(userId)} />
</Suspense>
The component is simpler. The loading state is handled by <Suspense>. And the data fetching logic is pushed to the parent, which is where it belongs.
What I’d Do Differently
Lesson learned: I should have run the React Compiler codemod before deploying. It would have caught the findDOMNode issue earlier. Always run npx react-compiler-healthcheck first.
Also, I migrated everything at once. If I had to do it again, I’d:
- Upgrade React 18 → 19 first (just the package)
- Run the healthcheck and fix breaking changes
- Enable the React Compiler incrementally (component by component)
- Replace form libraries one page at a time
Should You Migrate?
The only reason to wait: if you depend on a library that hasn’t been updated for React 19. Check first.
The Bottom Line
React 19 is the easiest major upgrade I’ve done in 8 years of using React. The breaking changes are minimal (mostly findDOMNode removal), the React Compiler is genuinely transformative, and useActionState simplifies a whole category of boilerplate.
If you’re still on React 18, there’s no reason to wait. The upgrade takes 15 minutes. The Compiler will make your app faster. And your codebase will be cleaner.