React 19 Stable: Everything That Changed and How to Migrate
React 19 Stable: Everything That Changed and How to Migrate#
TL;DR
- React 19 ships Actions,
use(),useActionState,useFormStatus,useOptimistic, ref as prop, document metadata, stylesheet management, and resource preloading APIs- The fundamental mental shift: forms submit to actions and React manages the async lifecycle, rather than you wiring
useState+onSubmit+ try/catch manually- Migration from React 18 is one of the smoother major React upgrades -- the main breaks are
ReactDOM.renderremoval, ref callback cleanup semantics, and some type changes- My recommendation: if you're on React 18.3 with App Router, migrate now; if you're on Pages Router, wait until your next major refactor
I've been running React 19 since the RC. My two production apps (EduPlay and MoneyTrack) have been on it for weeks now without major issues. What follows is the reference guide I built for myself during that migration, cleaned up enough that other people might find it useful.
The Core Mental Shift: Actions#
The single biggest change in React 19 isn't a hook or an API -- it's a different way of thinking about mutations.
In React 18 and earlier, the pattern was: user fills out form, onSubmit handler reads the data, calls an API, manages isLoading and error state manually, maybe calls e.preventDefault() (and you forget it once and wonder why the page refreshed). You've written this code hundreds of times. I have too.
React 19 introduces Actions: async functions you pass directly to <form action={...}>. React manages the pending state, the error state, the optimistic updates. You write less code and the behavior is more correct.
// React 18: the ceremony we've been writing for years
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await submitContactForm({ name, email });
} catch (err) {
setError("Something went wrong.");
} finally {
setIsLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
);
}
// React 19: let React handle the lifecycle
import { useActionState } from "react";
import { submitContactAction } from "./actions";
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContactAction, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="name" />
<input name="email" type="email" />
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
This isn't just fewer lines. The action-based form works with JavaScript disabled -- real progressive enhancement, not the kind where you say "we support it" but never test it. And React handles the transition scheduling, so the pending state integrates cleanly with Suspense boundaries and concurrent features.
useActionState#
This is the hook you'll use most from React 19. It connects a form action to component state:
const [state, dispatch, isPending] = useActionState(action, initialState);
The action receives previous state as its first argument and FormData as its second. It returns the next state. React handles the async lifecycle.
Here's a real example -- a profile update form with server-side validation:
// Server action
"use server";
export async function updateProfileAction(
prevState: { error: string | null; success: boolean },
formData: FormData
): Promise<{ error: string | null; success: boolean }> {
const displayName = formData.get("displayName") as string;
if (!displayName || displayName.length < 2) {
return { error: "Display name must be at least 2 characters.", success: false };
}
await updateUserProfile({ displayName });
return { error: null, success: true };
}
// Client component
"use client";
import { useActionState } from "react";
import { updateProfileAction } from "./actions";
export function ProfileForm({ currentDisplayName }: { currentDisplayName: string }) {
const [state, formAction, isPending] = useActionState(updateProfileAction, {
error: null,
success: false,
});
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="displayName" className="block text-sm font-medium">
Display Name
</label>
<input
id="displayName"
name="displayName"
defaultValue={currentDisplayName}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Saving..." : "Save"}
</button>
{state.error && (
<p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{state.error}</p>
)}
{state.success && (
<p className="rounded bg-green-50 px-3 py-2 text-sm text-green-700">Profile updated.</p>
)}
</form>
);
}
Notice: no useState for form fields. defaultValue + FormData is enough. No useState for loading. No useState for error. useActionState handles all of it. My form components got dramatically shorter after this migration. Not slightly -- dramatically.
useFormStatus#
This one solves a specific annoyance that's bothered me for years: how do you let a submit button know the form is pending without prop drilling?
import { useFormStatus } from "react-dom";
function SubmitButton({ label, pendingLabel }: { label: string; pendingLabel: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
{pending ? pendingLabel : label}
</button>
);
}
// Drop it into any form
function InviteForm() {
return (
<form action={sendInviteAction}>
<input name="email" type="email" placeholder="colleague@company.com" />
<SubmitButton label="Send Invite" pendingLabel="Sending..." />
</form>
);
}
The gotcha that will bite you: useFormStatus reads the status of the parent <form>. It doesn't work in the same component where you render the <form> element -- it has to be in a child component. I spent 20 minutes wondering why pending was always false before I re-read the docs. Put the button in its own component and it works perfectly.
The use() Hook#
This is the weird one. use() breaks the fundamental rule of hooks -- it can be called conditionally. It reads from two sources: Promises and Context.
Reading a Promise:
import { use, Suspense } from "react";
function UserProfile({ profilePromise }: { profilePromise: Promise<UserProfile> }) {
const profile = use(profilePromise);
return (
<div>
<h1>{profile.displayName}</h1>
<p>{profile.bio}</p>
</div>
);
}
// Parent passes the promise, Suspense handles the wait
function ProfilePage({ userId }: { userId: string }) {
const profilePromise = fetchUserProfile(userId);
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile profilePromise={profilePromise} />
</Suspense>
);
}
Reading Context conditionally -- this was impossible with useContext:
function ThemeAwareIcon({ condition, ...props }: IconProps) {
if (!condition) return null;
// this is valid in React 19 -- use() doesn't follow hook rules
const theme = use(ThemeContext);
return <Icon color={theme.iconColor} {...props} />;
}
I'll be honest: I'm still forming my opinion on use(). The Promise reading has a sharp edge (see Common Pitfalls below), and the conditional Context reading is useful but niche. I've used it in maybe three places across both production apps. It's not transformative in the way useActionState is. But it's there when you need it.
useOptimistic#
I wrote a whole post about this one (#28), so I'll keep it brief. useOptimistic gives you speculative state that reverts automatically when the enclosing transition settles:
const [optimisticLikes, addOptimistic] = useOptimistic(
actualLikeCount,
(current, delta: number) => current + delta
);
// inside a transition:
addOptimistic(1); // instantly shows +1
await toggleLike(); // when this resolves, reverts to actualLikeCount
The automatic revert is the entire value proposition. No more catch (err) { setCount(prev => prev - 1) }. I covered the patterns and gotchas in detail in the previous post.
Ref as Prop#
forwardRef is dead. Long live ref as a regular prop.
// React 18: the forwardRef dance
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ onSearch, placeholder }, ref) => (
<input
ref={ref}
placeholder={placeholder}
onChange={(e) => onSearch(e.target.value)}
className="rounded border px-3 py-2"
/>
)
);
// React 19: ref is just a prop, like it always should have been
function SearchInput({
ref,
onSearch,
placeholder,
}: SearchInputProps & { ref?: React.Ref<HTMLInputElement> }) {
return (
<input
ref={ref}
placeholder={placeholder}
onChange={(e) => onSearch(e.target.value)}
className="rounded border px-3 py-2"
/>
);
}
forwardRef still works -- it's not removed, just unnecessary. I went through both apps and unwrapped every forwardRef. It took about an hour and removed ~150 lines of wrapping boilerplate. Not revolutionary, but it felt good. Components are functions again, not HOC-wrapped functions pretending to be functions.
Document Metadata#
You can now render <title>, <meta>, and <link> tags from inside components. React hoists them to <head>:
function ArticlePage({ article }: { article: Article }) {
return (
<>
<title>{article.title} — My Blog</title>
<meta name="description" content={article.excerpt} />
<meta property="og:title" content={article.title} />
<meta property="og:image" content={article.coverImageUrl} />
<article>
<h1>{article.title}</h1>
{/* ... */}
</article>
</>
);
}
If you're on Next.js App Router, you already have generateMetadata which does more (SSR, streaming, deduplication). This API is more useful for non-Next React apps or for components that need to dynamically update meta tags on the client. I use generateMetadata for everything in my Next.js apps and haven't needed this yet, but it's nice to have for the escape hatch.
Stylesheet Support#
React 19 manages stylesheet precedence and deduplication:
function ThemedDashboard() {
return (
<>
<link rel="stylesheet" href="/styles/base.css" precedence="default" />
<link rel="stylesheet" href="/styles/dashboard.css" precedence="high" />
<DashboardLayout />
</>
);
}
The precedence attribute controls insertion order. Same stylesheet href? Only loaded once, even if multiple components reference it. In practice this is most useful for micro-frontend setups or lazy-loaded routes that bring their own styles.
Resource Preloading APIs#
New imperative functions from react-dom for telling the browser about resources ahead of time:
import { prefetchDNS, preconnect, preload, preinit } from "react-dom";
function ProductGrid({ products }: { products: Product[] }) {
// warm the CDN connection while the grid renders
preconnect("https://images.cdn.example.com");
// preload the first product image (probably the LCP element)
if (products[0]?.imageUrl) {
preload(products[0].imageUrl, { as: "image" });
}
return (
<ul>
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
);
}
These are most impactful for performance-sensitive pages where you know ahead of time what resources you'll need. I've used preconnect for our CDN and preload for hero images. Measurable LCP improvements with two lines of code.
Migrating from React 18#
I migrated two apps. Here's what you actually need to do, ranked by likelihood of affecting you:
1. ReactDOM.render and ReactDOM.hydrate are removed. If you're on App Router you're already using createRoot/hydrateRoot and this doesn't matter. If you have legacy entry points, update them.
2. Ref cleanup functions. React 19 supports returning a cleanup function from ref callbacks. But this means the types changed -- if you return a function from a ref callback, TypeScript expects it to be the cleanup, not an accidental return. This broke two of my components where I had ref callbacks that implicitly returned something.
// React 19: if you return from a ref callback, it must be a cleanup function
<div
ref={(node) => {
if (node) {
const observer = new ResizeObserver(handleResize);
observer.observe(node);
return () => observer.disconnect(); // cleanup
}
// don't also return undefined -- pick one path
}}
/>
3. useContext still works. You do not need to refactor anything. use(Context) is an opt-in addition, not a replacement.
4. forwardRef still works. Your existing wrappers won't break. You can unwrap them at your leisure.
5. Strict Mode double-render is gone. React 19 Strict Mode no longer double-invokes render functions. If you had code that "worked" because the double-render masked a timing bug, that bug might surface now. This is a good thing, even if it's temporarily annoying.
6. Third-party library types. This was my biggest pain point, honestly. Several UI libraries hadn't updated their types for the new ref behavior when I migrated. I had to pin versions and add type assertions in a few spots. Most libraries have caught up by now (December 2024), but check your major deps before upgrading. The React team maintains a list of known library issues in the migration guide.
My Take#
React 19 is the most important release since hooks in 16.8. That's not hype -- it's a structural observation. Hooks changed how we compose logic. Actions change how we handle mutations. Both shifts are fundamental.
The piece that impressed me most isn't any single API -- it's how they compose together. useActionState for the form lifecycle. useFormStatus for child components that need pending state. useOptimistic for instant feedback. use() for conditional data reading. Server actions for the backend glue. Each one is simple. Together they replace an enormous amount of bespoke state management code.
I've been burned before by shiny new React APIs (looking at you, Suspense-for-data-fetching circa 2020). But this release actually delivered. The code I'm writing today with React 19 is shorter, more correct, and more readable than the React 18 equivalent. Not marginally -- noticeably.
Common Pitfalls#
Calling use() with a Promise created during render. This is the sharpest edge in the whole release. If your component creates a new Promise every render, use() will suspend every render. The Promise needs a stable identity -- create it in a parent component, a Server Component, or a cache. I hit this on day two of the migration and wasted an hour before I understood what was happening.
useFormStatus in the wrong component. Already mentioned this but it's worth repeating because it bites everyone. The hook reads the parent form's status. If pending is always false, you put it in the same component as the <form>, not a child. Move it to a child component.
Confusing useActionState state with server data. The state from useActionState is the return value of your action function. It's not a cache. It's not server state. If you need to invalidate server data after a mutation, use revalidatePath or revalidateTag in your server action.
Trying to use addOptimistic outside a transition. It only works inside startTransition or a form action. If you call it outside that scope, React warns in dev mode but silently does nothing in production. Ask me how I know.
Not running the official codemod first. The React team shipped codemods for the breaking changes. Run them before doing anything manual. I didn't on MoneyTrack and spent time fixing things the codemod would have handled automatically.
Recommendations by Project Size#
Small project / side project: Upgrade immediately. The new form APIs will save you time from day one. Breaking changes are minimal.
Medium app (10-50 components with forms): Plan a focused migration sprint. Run the codemod, audit your third-party deps for type compatibility, migrate forms one at a time. I'd budget 2-3 days.
Large app / monorepo: Migrate module by module if your architecture supports it (hexagonal architecture makes this natural). Don't try to migrate all forms at once. Start with the highest-traffic form, prove the pattern works, then roll out to the rest.
Still on Pages Router: This is an honest answer you won't hear from the React team -- the new APIs are designed for App Router and Server Components. They still work in Pages Router, but you won't get server actions or progressive enhancement. I'd prioritize the App Router migration first, then upgrade to React 19.
In the next post I'll share what actually happened when I ran React 19 in production for three months -- specific numbers, specific bugs, and honest answers about whether the theory held up under real traffic.