React 19: Everything Developers Need to Know

I cover the meaningful changes in React 19 — Actions, useActionState, the use() hook, React Compiler, document metadata, ref as prop, and how to upgrade.

React 19: Everything Developers Need to Know

When React 19 shipped in December 2024, the community reaction was quieter than most major releases. The changelog covered a lot of ground — new hooks, compiler tooling, server-side changes — but nothing that screamed “drop everything and migrate.” Most developers bookmarked the post and went back to work.

That’s understandable. None of the individual changes is dramatic on its own. What you miss by reading them separately is the pattern: React 19 takes five things developers have been wiring together by hand for years and absorbs them into the framework. Pending state for forms. Reading async data in render. Memoisation. Page metadata. Ref forwarding. All of them now have a first-class React solution.

I’ll walk through the changes that actually affect daily development, show the before-and-after code, and share what’s worth adopting now.

On this page

Actions took the boilerplate out of async form submissions

Every async form in React used to need the same three state variables: the value, the pending flag, and the error. This pattern appears so often it stops feeling like boilerplate — it just becomes “what you write before the real logic.”

// Before React 19
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);
    const err = await updateName(name);
    setIsPending(false);
    if (err) {
      setError(err);
      return;
    }
    redirect("/dashboard");
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

React 19 calls async functions used inside startTransition Actions. The pending state is managed for you. Combine this with the new useActionState hook and the form drops to one state variable:

import { useActionState } from "react";

function UpdateName() {
  const [error, submitAction, isPending] = useActionState(
    async (_prev: string | null, formData: FormData) => {
      const err = await updateName(formData.get("name") as string);
      if (err) return err;
      redirect("/dashboard");
      return null;
    },
    null
  );

  return (
    <form action={submitAction}>
      <input type="text" name="name" />
      <button type="submit" disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </form>
  );
}

useActionState takes your async function and an initial state, then returns [state, wrappedAction, isPending]. When the form submits, isPending is immediately true. When the action finishes, isPending resets, and the action’s return value becomes the new state — the error string in this example. When the action succeeds, the native form resets its uncontrolled fields automatically.

The useOptimistic hook pairs naturally with Actions when you want to show the expected outcome before the server responds — useful for toggles, reorder lists, or anything where the delay would look wrong.

Try this

  1. Find a component that manages isPending manually with useState.
  2. Replace the pending state and handler with useActionState.
  3. Pass the wrapped action to <form action={...}>.

Expected result: The form behaves identically with fewer state variables and no manual flag management.

Check this before moving on

  • useActionState is imported from "react", not "react-dom"
  • The action signature is (previousState, formData) => Promise<state>
  • The action’s return value replaces the state — not a separate setter call
  • useFormStatus (from "react-dom") gives child components access to the parent form’s pending state without prop drilling

The use() hook reads resources in render

use() is a new React API — not quite a hook, but called in the render phase — that reads promises and Context directly.

import { use, Suspense } from "react";

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise); // suspends until resolved
  return comments.map((c) => <p key={c.id}>{c.body}</p>);
}

function Page({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

When use() receives an unresolved promise, the component suspends. The nearest <Suspense> boundary shows its fallback. Once the promise resolves, the component re-renders with the result.

Two things worth knowing about this API:

use() can be called conditionally. Every hook follows the Rules of Hooks — no calls after early returns. use() is different. You can call it inside an if block or after an early return. This matters when you want to read a Context only in a specific branch of your component.

The promise must come from somewhere stable. Passing use(fetch('/api/data')) — a new promise on every render — causes infinite suspension. The promise needs to come from a prop, a server component, or a Suspense-aware library. Frameworks like Next.js handle the caching side; if you’re building without a framework, you’ll need to manage this yourself.

React Compiler handles memoisation automatically

The React Compiler is a stable build-time tool that analyses your component code and adds React.memo, useMemo, and useCallback where they’re needed. You write components normally; the compiler decides what to memoize.

// What you write
function SortedList({ items }: { items: Item[] }) {
  const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
  return sorted.map((item) => <ItemRow key={item.id} item={item} />);
}

// What the compiler generates (roughly)
const SortedList = React.memo(function SortedList({ items }) {
  const sorted = useMemo(
    () => [...items].sort((a, b) => a.name.localeCompare(b.name)),
    [items]
  );
  return sorted.map((item) => <ItemRow key={item.id} item={item} />);
});

The Meta data from their rollout surprised me. Manual memoisation appeared in only 8% of React pull requests at Meta — but those PRs took 31–46% longer to author after controlling for diff size and complexity. We already knew memo was tedious. Now there’s actual data on how much time it costs.

To add the Compiler to a Vite project:

npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler
// vite.config.ts
export default defineConfig({
  plugins: [
    react({ babel: { plugins: [["babel-plugin-react-compiler"]] } }),
  ],
});

The Compiler works on React 17 and 18 too — add react-compiler-runtime for older versions. One constraint: it only optimises code that follows the Rules of React. Mutations in render, incorrect dependency patterns, and side effects in render cause the Compiler to skip those components rather than produce incorrect output. The ESLint plugin (eslint-plugin-react-compiler) surfaces violations before the Compiler hits them.

For code you’re already tuning with manual memo, this is a low-risk addition. For new code, I’d stop writing React.memo entirely and let the Compiler handle it.

Document metadata belongs inside your components

HTML metadata — <title>, <meta>, <link> — has always been awkward in React. The component that renders a blog post has no direct path to the <head> of the document it lives in. Libraries like react-helmet filled that gap for years. React 19 adds it natively.

function PostDetail({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      {/* React hoists these to <head> automatically */}
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <link rel="canonical" href={post.url} />
      <p>{post.body}</p>
    </article>
  );
}

React sees those tags and moves them to <head> — regardless of where in the component tree they appear. This works in client-only apps, during streaming SSR, and in Server Components. No wrapper component, no effect, no third-party library for the common case.

For complex metadata — route-aware overrides, conflicting tags across nested layouts, priority stacking — a library still makes sense. But “this component declares its page title” is now enough on its own.

ref is now a regular prop

Function components no longer need React.forwardRef to accept a ref from a parent. In React 19, ref is just a prop.

// Before React 19
const TextInput = React.forwardRef<HTMLInputElement, { label: string }>(
  ({ label }, ref) => <input aria-label={label} ref={ref} />
);

// React 19
function TextInput({
  label,
  ref,
}: {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
}) {
  return <input aria-label={label} ref={ref} />;
}

forwardRef is deprecated but not removed. A codemod handles the migration automatically. Class component refs are unchanged — ref on a class still refers to the component instance, not a prop.

React 19 also adds support for cleanup functions returned from ref callbacks. When the element unmounts, React calls the returned function — useful for detaching event listeners or cleaning up third-party integrations tied to a DOM node.

Server Components are fully stable — and commonly misunderstood

React 19 stabilises the full Server Components feature set. This doesn’t mean Server Components are new — they’ve shipped in Next.js since 2023. It means the underlying APIs are stable enough that bundlers and frameworks can now target them without pinning to experimental channels.

The most common confusion is about the "use server" directive. It does not mark a Server Component. There is no directive for Server Components.

// Server Action — this runs on the server when called from the client
"use server";
export async function createPost(formData: FormData) {
  await db.posts.create({ title: formData.get("title") as string });
}

// Server Component — async function, no directive, rendered on the server
export default async function PostsPage() {
  const posts = await db.posts.findMany();
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

"use server" marks a Server Action: an async function the client can call, which executes on the server. Server Components are the default in RSC-aware frameworks — they’re just async functions that render on the server and return JSX.

If you’re using Next.js with the App Router, this changes nothing in practice. The Next.js App Router guide covers the full setup if you’re getting started.

What actually breaks when you upgrade

The upgrade is straightforward for most apps. The breaking changes most likely to affect production code:

What changed What to do
ReactDOM.render removed Switch to createRoot (deprecated since React 18)
ReactDOM.hydrate removed Switch to hydrateRoot
useFormState renamed to useActionState Run the React 19 codemod
Ref callback implicit returns Use block syntax: ref={(el) => { instance = el; }}
act() is now async-only Update test wrappers if you’re on an older test setup

React 18.3 added deprecation warnings for most of these before React 19 dropped. If you’ve been treating warnings as errors in development, you’ve likely already fixed them.

Run npx codemod@latest react/19/migration-recipe before upgrading — it handles forwardRef removal, useFormState renaming, and several other mechanical changes automatically. The official upgrade guide has the full list of breaking changes with before/after examples.

React 19 is removing the ceremony, not the concepts

You don’t need to rewrite working code. React.memo still works. forwardRef still works. react-helmet still works. React 19 doesn’t break the patterns you’re using — it offers better alternatives.

The conceptual shift is this: every pattern React 19 improves was a workaround for something the framework didn’t natively support. Forms needed three state variables because transitions didn’t handle async functions. Refs needed wrapping because props couldn’t carry a ref. Metadata needed a library because components had no path to <head>.

React 19 closes those gaps.

The change worth making immediately is useActionState for any form that does async work. The difference in code clarity is visible in a single diff, requires no infrastructure changes, and works the same whether you’re using Server Components or not. For existing apps, the Compiler is the highest-leverage addition with the least migration risk — add the plugin, fix ESLint violations, and let the build step handle the rest.

If you want a foundation before tackling these APIs, my post on React hooks you’ll actually use covers the core state model. For the performance side, the React performance guide for slow components explains the problems the Compiler is solving. And if you’re managing global state alongside these changes, Context, Redux, and Zustand compared explains when each approach still makes sense.

Six geometric nodes orbiting a central hexagonal frame connected by spoke lines, representing the six capability areas added in React 19 around a stable component core.

React 19 adds six capability areas — Actions, the use() hook, the Compiler, document metadata, ref as prop, and stable Server Components — all integrated around the same component model.

Sources