React 19: The API Changes That Actually Matter

React 19 arrived on December 5, 2024. For many teams, the upgrade path is smooth; for experienced TypeScript developers, the upgrade is also an opportunity to delete patterns that existed only to paper over older limitations. The changes worth internalizing are the ones that alter how you design component APIs, how you wire context, how you integrate async work with Suspense, and how you model forms and metadata without scattering effects across the tree.

Ref as a prop: the end of forwardRef as default

For years, function components could not receive a ref the same way class components did. The workaround was forwardRef, which split props and ref into separate parameters and often forced awkward typing gymnastics in design systems.

React 19 treats ref as a regular prop. You can destructure it like anything else and pass it through:

type MyInputProps = React.ComponentPropsWithoutRef<"input">;

function MyInput({ ref, ...props }: MyInputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

In practice, many teams lean on ComponentPropsWithRef for elements they wrap:

type MyInputProps = React.ComponentPropsWithRef<"input">;

function MyInput({ ref, ...rest }: MyInputProps) {
  return <input ref={ref} {...rest} />;
}

forwardRef still works for backward compatibility, and codemods exist to migrate call sites, but the direction of travel is clear: library authors should stop introducing new forwardRef wrappers unless they are bridging legacy consumers. The payoff is not only less boilerplate; it is a more honest component signature. Consumers see ref in the same object as the rest of the contract, which makes generic and polymorphic components easier to type consistently.

Context without .Provider

Context values are now easier to express at the point of use. Instead of nesting a .Provider, you can render the context object itself as a provider:

import { createContext, use } from "react";

const ThemeContext = createContext<"light" | "dark">("light");

function App({ children }: { children: React.ReactNode }) {
  return <ThemeContext value="dark">{children}</ThemeContext>;
}

function Panel() {
  const theme = use(ThemeContext);
  return <div data-theme={theme}>…</div>;
}

This reads more like ordinary JSX and reduces the visual noise that made context hierarchies feel “special” compared to other composition. For TypeScript, the win is the same as always—the value type lives in one place—but the call-site ergonomics improve enough that teams reach for context for mid-sized subtrees without immediately regretting the indentation.

Ref callbacks with cleanup

Ref callbacks have gained a subtle but powerful capability: they may return a cleanup function, which React invokes when the ref is cleared or the component unmounts. That mirrors the mental model of effects, but localized to the lifecycle of a specific DOM node or instance.

function Measured({ onMeasure }: { onMeasure: (w: number) => void }) {
  return (
    <div
      ref={(el) => {
        if (!el) return;
        const ro = new ResizeObserver(() => onMeasure(el.getBoundingClientRect().width));
        ro.observe(el);
        return () => ro.disconnect();
      }}
    />
  );
}

Patterns that previously required useLayoutEffect plus careful guard clauses can sometimes collapse into a ref callback with a cleanup, which keeps DOM subscriptions closer to the element they describe. TypeScript does not need anything exotic here: the callback’s return type naturally includes void | (() => void).

use: Promises in render, Suspense at the boundary

The use hook lets you read a Promise directly during render. When the Promise is pending, React suspends to the nearest Suspense boundary; when it resolves, render continues with the unwrapped value. This is not a replacement for every data-fetching approach, but it is a crisp composition primitive when you already structure loading with Suspense.

import { Suspense, use } from "react";

async function loadMessage(): Promise<string> {
  await new Promise((r) => setTimeout(r, 250));
  return "hello";
}

const messagePromise = loadMessage();

function Message() {
  const text = use(messagePromise);
  return <p>{text}</p>;
}

export function Screen() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Message />
    </Suspense>
  );
}

In real applications, promises often come from a cache or router loader rather than a module-level singleton; the important part is the contract: use participates in React’s concurrency story the way Suspense expects, rather than bolting async/await onto render with ad hoc state.

TypeScript fits cleanly when your cache returns well-typed promises (Promise<User>, Promise<Post[]>). The failure modes you still own are the same as always—error boundaries, stale data, and invalidation—not any leaking through the hook boundary.

Forms: useActionState, useOptimistic, and useFormStatus

React 19 sharpens the form story around actions. useActionState (the evolution of what many codebases knew as useFormState) pairs UI state with a server or client action, giving you a structured reducer-like loop for validation messages, field errors, and transitions.

"use client";

import { useActionState } from "react";

type FormState = { error: string | null };

async function createUser(_prev: FormState, formData: FormData): Promise<FormState> {
  const name = String(formData.get("name") ?? "");
  if (!name) return { error: "Name is required" };
  await fetch("/api/users", { method: "POST", body: JSON.stringify({ name }) });
  return { error: null };
}

export function CreateUserForm() {
  const [state, action, isPending] = useActionState(createUser, { error: null });

  return (
    <form action={action}>
      <input name="name" />
      <button disabled={isPending}>Save</button>
      {state.error ? <p role="alert">{state.error}</p> : null}
    </form>
  );
}

useOptimistic layers immediate UI feedback on top of async work, with automatic rollback when the action finishes or fails—think liked counts, toggled flags, or list inserts that should feel instant while the network disagrees temporarily.

"use client";

import { useOptimistic } from "react";

type Todo = { id: string; title: string; done: boolean };

export function TodoList({
  todos,
  toggle,
}: {
  todos: Todo[];
  toggle: (id: string) => Promise<void>;
}) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, update: { id: string; done: boolean }) =>
      state.map((item) => (item.id === update.id ? { ...item, done: update.done } : item)),
  );

  return (
    <ul>
      {optimisticTodos.map((t) => (
        <li key={t.id}>
          <label>
            <input
              type="checkbox"
              checked={t.done}
              onChange={() => {
                addOptimistic({ id: t.id, done: !t.done });
                void toggle(t.id);
              }}
            />
            {t.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

useFormStatus lets child components read the pending state of a parent <form> without prop drilling—useful for disabling ancillary controls or showing spinners next to the right label.

"use client";

import { useFormStatus } from "react-dom";

function SubmitLabel({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return <span>{pending ? "Saving…" : children}</span>;
}

export function SettingsForm({ action }: { action: (formData: FormData) => Promise<void> }) {
  return (
    <form action={action}>
      <input name="name" />
      <button type="submit">
        <SubmitLabel>Save changes</SubmitLabel>
      </button>
    </form>
  );
}

Together, these hooks push form code toward declared relationships between UI and actions instead of hand-rolled useState plus useEffect chains. TypeScript shines when FormState is explicit: your transitions become a small state machine the compiler can track.

Document metadata: title, meta, and stylesheets in the tree

React 19 expands first-class support for hoisting document metadata from components—<title>, <meta>, and stylesheet links—so that concurrent rendering and streaming can reason about them coherently. Colocating metadata with the feature that owns it reduces the “mystery effect in useEffect” anti-pattern and makes it easier to ensure the correct head output when multiple routes or layouts overlap.

The precise APIs evolve with your meta-framework, but the underlying shift matters for TypeScript developers architecturally: treat head concerns as part of the component contract, not as a side channel.

What to do in a mature codebase

If you are planning an upgrade, prioritize ref and context migrations in design-system and shared UI packages first—those propagate everywhere. Adopt use only where you already have Suspense boundaries you trust, and introduce form hooks where actions are standardized (Next.js server actions, your own action layer, or progressively enhanced endpoints).

React 19’s headline features get the tweets, but the durable story is simpler: fewer special cases in component signatures, better alignment with Suspense, and hooks that encode patterns you were already trying to hand-roll—now with a stable, typed surface.