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.