Migrating to React 19: Complete Refactoring Guide
Migrating from React 18 to React 19 is straightforward because the core APIs remain backward-compatible. There are no hard breaking changes—your React 18 app will run unchanged on React 19. The challenge is choosing which new features to adopt and in what order. This guide walks through a pragmatic migration strategy: upgrade React and dependencies, apply codemods for low-risk refactors, then incrementally adopt new features where they provide value.
The goal isn't to rewrite everything at once. Instead, migrate incrementally: update one feature set per pull request, test thoroughly, and leave legacy patterns in place where they still work. By the end, you'll have a more maintainable codebase with 15–40% less boilerplate.
Pre-Migration Checklist
Before upgrading, ensure your project is in a good state:
- Run tests — Make sure your test suite passes on React 18.
- Update dependencies — Upgrade TypeScript, Next.js/Remix, and other framework/libraries to versions that support React 19.
- Check browser targets — React 19 requires modern browser APIs. If you target IE 11 or old Android versions, test thoroughly.
- Review third-party libraries — Check that critical dependencies (state management, UI libraries) are React 19-compatible.
Step 1: Update React and Dependencies
Upgrade React to version 19 and react-dom:
npm install react@latest react-dom@latest
npm install -D @types/react@latest @types/react-dom@latest # if using TypeScript
If you use a framework like Next.js or Remix, upgrade it too:
npm install next@latest # Next.js 15+
npm install remix@latest # Remix 2.12+
Test that your app still builds and runs. Most React 18 apps will work unchanged on React 19.
Step 2: Apply Codemods for forwardRef Removal
React 19 makes ref a standard prop, eliminating the need for forwardRef. Use the official codemod to automate this refactor:
npx react-codemod-forward-ref
This codemod:
- Removes
forwardRefwrappers - Adds
refto component function signatures - Updates TypeScript types
Before:
import { forwardRef } from 'react';
const Input = forwardRef(({ label, ...props }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
</div>
);
});
After:
function Input({ ref, label, ...props }) {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
</div>
);
}
The codemod is safe—it only affects forwardRef components, and you can review and commit the changes incrementally.
Step 3: Migrate useState-Based Forms to useFormState
Form handling is where React 19 provides the most value. Identify forms using multiple useState calls for state, errors, and submission:
Before:
function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setErrors({});
try {
const result = await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!result.ok) {
const data = await result.json();
setErrors(data.errors);
} else {
window.location.href = '/dashboard';
}
} catch (error) {
setErrors({ general: 'Network error' });
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{errors.email && <span>{errors.email}</span>}
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
{errors.password && <span>{errors.password}</span>}
<button disabled={isLoading}>{isLoading ? 'Signing up...' : 'Sign up'}</button>
</form>
);
}
After (with React 19):
import { useFormState, useFormStatus } from 'react-dom';
import { signup } from './actions';
function SignupButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? 'Signing up...' : 'Sign up'}
</button>
);
}
function SignupForm() {
const initialState = { errors: {} };
const [state, formAction] = useFormState(signup, initialState);
return (
<form action={formAction}>
<input name="email" />
{state.errors.email && <span>{state.errors.email}</span>}
<input name="password" type="password" />
{state.errors.password && <span>{state.errors.password}</span>}
<SignupButton />
</form>
);
}
The Server Action:
'use server';
export async function signup(prevState, formData) {
const email = formData.get('email');
const password = formData.get('password');
try {
const result = await createUser({ email, password });
if (result.ok) {
redirect('/dashboard');
}
return { errors: result.errors };
} catch (error) {
return { errors: { general: 'An error occurred' } };
}
}
This refactor reduces code from ~30 lines to ~15 lines and eliminates manual error handling.
Step 4: Replace Optimistic Updates with useOptimistic
If your app has custom optimistic update logic (messaging, likes, comments), replace it with useOptimistic:
Before:
function Comment({ comment, onOptimisticDelete }) {
const [isDeleting, setIsDeleting] = useState(false);
const [deleted, setDeleted] = useState(false);
const handleDelete = async () => {
setDeleted(true);
setIsDeleting(true);
try {
await deleteComment(comment.id);
} catch {
setDeleted(false);
alert('Failed to delete');
} finally {
setIsDeleting(false);
}
};
if (deleted) return null;
return (
<div>
<p>{comment.text}</p>
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
);
}
After:
import { useOptimistic, useTransition } from 'react';
function Comment({ comment }) {
const [optimisticComment, deleteOptimistic] = useOptimistic(
comment,
(state) => ({ ...state, deleted: true })
);
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
deleteOptimistic();
startTransition(async () => {
try {
await deleteComment(comment.id);
} catch {
// Automatically reverts the optimistic delete
}
});
};
if (optimisticComment.deleted) return null;
return (
<div>
<p>{optimisticComment.text}</p>
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
);
}
Step 5: Update Document Metadata from Helmet to Native
If you use react-helmet, migrate to native metadata:
Before:
import { Helmet } from 'react-helmet-async';
function BlogPost({ post }) {
return (
<>
<Helmet>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta property="og:image" content={post.image} />
</Helmet>
<article>{/* content */}</article>
</>
);
}
After:
function BlogPost({ post }) {
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta property="og:image" content={post.image} />
<article>{/* content */}</article>
</>
);
}
Remove the Helmet provider from your app root. React 19 handles metadata automatically.
Migration Order and Priorities
Migrate features in this order for maximum impact with minimal risk:
| Priority | Feature | Effort | Impact |
|---|---|---|---|
| 1 | Remove forwardRef | Low | Medium—cleaner code |
| 2 | Migrate forms to useFormState | Medium | High—less boilerplate |
| 3 | Replace optimistic updates | Medium | High—simpler logic |
| 4 | Remove react-helmet | Low | Medium—smaller bundle |
| 5 | Adopt useResourcePreload | Low | Low–Medium—conditional |
| 6 | Implement Server Actions | High | High—but framework-dependent |
Start with 1–2, test thoroughly, then move on. You don't need to complete all 6 to benefit from React 19.
Testing After Migration
After each refactor, run your test suite:
npm test
If tests fail, revert the change and investigate. Common issues:
- Form tests expect specific state management — Update tests to check rendered UI instead of internal state.
- Refs changed shape — Update ref assertions to reflect new usage patterns.
- Metadata not detected — Ensure metadata elements render at the top level of your component tree.
Rollback Plan
If migration goes wrong, rollback is trivial:
npm install react@18 react-dom@18
npm test
git revert <commit-hash>
Your React 18 app will work unchanged. React 19 features are opt-in, so partial migrations are safe.
Performance Expectations
After migrating to React 19, expect:
- Bundle size: -2–5% (fewer third-party libraries like Helmet, CSS-in-JS)
- Form interactions: 20–50% faster (no manual state juggling)
- Code lines: -15–40% per component (especially forms)
- Time to interactive: -100–300ms (smaller JS payload)
These are not automatic—they come from adopting new APIs incrementally.
Key Takeaways
- React 19 is backward-compatible; you can migrate incrementally without breaking existing code.
- Apply codemods first (
forwardRefremoval) for low-risk refactors. - Migrate forms to
useFormStatenext for the highest impact. - Test thoroughly after each refactor; rollback is safe and simple.
- Adopt new features where they solve specific problems, not everywhere.
- Full migration takes weeks to months depending on codebase size; that's normal.
Frequently Asked Questions
Can I use React 18 and React 19 features side-by-side?
Yes, completely. A single React 19 app can have some components using useFormState, others using useState. Mix and match freely.
How long will React 18 be supported?
React 18 remains fully supported. Major versions are supported for at least 2–3 years after release. There's no rush to upgrade.
Should I migrate my library to React 19?
Only if your consumers are on React 19. Libraries should maintain React 18 compatibility for as long as possible. You can test against both versions, but don't require React 19 unless it's a major version bump.
What about concurrent features? Do I need them?
Concurrent rendering is automatic in React 19 (Suspense, automatic batching). You don't need to do anything. useTransition is opt-in for advanced use cases.
Are there any breaking changes I should know about?
No breaking changes in userland code. Legacy patterns (class components, old context API, forwardRef) still work. The only difference is that new patterns are now available and simpler.