StackPractices
advanced By Mathias Paulenko

Complete Guide to React 19 Features

A practical walkthrough of React 19: server components, the use() hook, actions, ref as a prop, document metadata, asset loading, and the React Compiler.

Introduction

React 19 shipped two distinct sets of changes. The first is a batch of new primitives (use(), actions, useActionState, useOptimistic, useFormStatus, and ref as a regular prop) that change how you fetch data and handle forms. The second is infrastructure: server components moved from experimental to stable, <title> and <meta> can be rendered from any component, assets preload automatically, and the React Compiler memoizes your code at build time.

This guide walks through each feature with runnable TypeScript examples, explains when it actually helps, and ends with the breaking changes you’ll hit during migration. It assumes you’re comfortable with React 18 hooks and have a React 19 project — most examples run in Next.js 15 or a Vite + React 19 setup.

Server Components

Server Components run exclusively on the server and send rendered output to the client. Because they never ship to the browser, they can call databases, read files, and use private credentials without exposing anything — and they add zero kilobytes to your client bundle.

// app/users/page.tsx — Server Component
import { db } from "@/lib/db";

async function getUsers() {
  const users = await db.user.findMany({
    take: 20,
    orderBy: { createdAt: "desc" },
  });
  return users;
}

export default async function UsersPage() {
  const users = await getUsers();
  
  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name} — {user.email}
          </li>
        ))}
      </ul>
    </main>
  );
}

The boundary between server and client is drawn by the "use client" directive. Anything below it becomes client code; anything above stays on the server. You mix them by passing serializable props from a server component down to a client component — a common pattern is fetching data at the page level and handing it to an interactive widget.

// Mixing server and client components
// app/dashboard/page.tsx — Server Component
import { db } from "@/lib/db";
import { DashboardChart } from "./DashboardChart";

export default async function Dashboard() {
  const stats = await db.getStats();
  
  return (
    <main>
      <h1>Dashboard</h1>
      {/* Pass serializable data to client components */}
      <DashboardChart data={stats} />
    </main>
  );
}

// app/dashboard/DashboardChart.tsx — Client Component
"use client";

import { useState } from "react";

interface DashboardChartProps {
  data: { label: string; value: number }[];
}

export function DashboardChart({ data }: DashboardChartProps) {
  const [hovered, setHovered] = useState<number | null>(null);
  
  return (
    <div className="chart">
      {data.map((item, i) => (
        <div
          key={i}
          className="bar"
          style={{ height: `${item.value}%` }}
          onMouseEnter={() => setHovered(i)}
          onMouseLeave={() => setHovered(null)}
        >
          {hovered === i && <span className="tooltip">{item.value}</span>}
          <span className="label">{item.label}</span>
        </div>
      ))}
    </div>
  );
}

Server Component Rules

Server Components CAN:
  - Access databases, file systems, internal APIs
  - Import and use server-only libraries
  - Pass serializable props to client components
  - Use async/await directly

Server Components CANNOT:
  - Use useState, useEffect, useReducer, or any state hook
  - Use event handlers (onClick, onChange, etc.)
  - Use browser APIs (window, document, localStorage)
  - Import client-only libraries

Client Components CANNOT:
  - Access databases or file systems
  - Use server-only libraries
  - Import server components directly
  - Use async/await in render (use use() instead)

Passing data between them:
  - Server → Client: pass serializable props (strings, numbers, arrays, objects)
  - Client → Server: use server actions or API routes
  - Never pass functions, class instances, or non-serializable data

The “serializable props” rule trips people up most often: you can’t pass event handlers, class instances, Dates, Maps, or Sets across the boundary. If you need the client to trigger server-side work, expose a server action instead of trying to pass a callback. For a deeper dive into architecture patterns around this boundary, see the React Server Components guide.

The use() Hook

use() unwraps promises and reads context. Unlike every other hook, it can be called conditionally: inside if blocks, loops, or after early returns. That single property makes it the replacement for a whole category of useEffect + useState data-fetching boilerplate.

import { use } from "react";

// Unwrapping a promise
async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Parent creates the promise
function App({ userId }: { userId: string }) {
  const userPromise = getUser(userId);
  
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Two details matter here. First, the promise is created in the parent. Calling getUser() inside UserProfile during render would fire a new request on every render. Second, use() integrates with Suspense: the component suspends until the promise resolves, and the nearest <Suspense> boundary shows the fallback. You should also wrap the tree in an error boundary to catch rejections, or you’ll get an unhandled error instead of a retry path.

The second use case is reading context conditionally, which useContext could never do:

// use() with context — can be called conditionally
import { createContext, use, useContext } from "react";

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

function ThemedButton({ showTheme }: { showTheme: boolean }) {
  // use() can be called conditionally — useContext cannot
  if (showTheme) {
    const theme = use(ThemeContext);
    return <button className={`btn btn-${theme}`}>Click me ({theme})</button>;
  }
  return <button className="btn">Click me</button>;
}

When should you not use use()? If the data fetch must happen client-side on every mount (a live feed, a polling subscription), an effect with cleanup is still the right tool. use() is for “render once when this promise resolves,” not for subscriptions.

Actions and Form Actions

Actions are functions passed to a form’s action attribute. They replace the classic onSubmit + e.preventDefault() + fetch() ritual, and they enable progressive enhancement: the form submits via a normal POST even if JavaScript hasn’t loaded yet.

// Server action
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  
  if (!name || !email) {
    return { error: "Name and email are required" };
  }
  
  await db.user.create({
    data: { name, email },
  });
  
  revalidatePath("/users");
  return { success: true };
}

// Form using action
function CreateUserForm() {
  return (
    <form action={createUser}>
      <input name="name" type="text" required placeholder="Name" />
      <input name="email" type="email" required placeholder="Email" />
      <button type="submit">Create User</button>
    </form>
  );
}

A server action is still an HTTP endpoint under the hood, so treat its FormData as untrusted input: validate and authorize inside the action, never in the client component. The “progressive enhancement” benefit is real but easy to overstate — in a pure client-side SPA (no framework), actions behave like regular async functions called on submit; the no-JS form POST only works when a framework like Next.js wires it up.

useActionState

useActionState wires an action to component state: it returns the action’s last result, a wrapped version you pass to the form, and a pending flag, so you no longer hand-roll isSubmitting and error states.

import { useActionState } from "react";

interface State {
  error?: string;
  success?: boolean;
}

async function submitForm(prevState: State, formData: FormData): Promise<State> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  
  if (!name || name.length < 2) {
    return { error: "Name must be at least 2 characters" };
  }
  
  if (!email || !email.includes("@")) {
    return { error: "Valid email is required" };
  }
  
  try {
    const res = await fetch("/api/users", {
      method: "POST",
      body: formData,
    });
    
    if (!res.ok) {
      return { error: "Failed to create user" };
    }
    
    return { success: true };
  } catch {
    return { error: "Network error" };
  }
}

function UserForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    submitForm,
    {}
  );
  
  return (
    <form action={formAction}>
      <input name="name" type="text" placeholder="Name" />
      <input name="email" type="email" placeholder="Email" />
      
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </button>
      
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">User created!</p>}
    </form>
  );
}

Note the signature: your action receives (prevState, formData); the previous result comes first, not the form data. This hook was called useFormState in the experimental releases; if you find that name in older code or blog posts, it’s the same API.

useOptimistic

useOptimistic lets the UI reflect a mutation instantly, before the server confirms it. You give it the current state and a reducer that describes how a pending item should look; React rolls the optimistic value back automatically when the action finishes.

import { useOptimistic } from "react";

interface Message {
  id: string;
  text: string;
  sent: boolean;
}

function MessageList({ messages, sendMessage }: {
  messages: Message[];
  sendMessage: (text: string) => Promise<void>;
}) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: Message) => [
      ...state,
      { ...newMessage, sent: false },
    ]
  );
  
  async function handleSubmit(formData: FormData) {
    const text = formData.get("message") as string;
    addOptimisticMessage({ id: crypto.randomUUID(), text, sent: false });
    await sendMessage(text);
  }
  
  return (
    <div>
      <ul>
        {optimisticMessages.map((msg) => (
          <li key={msg.id} className={msg.sent ? "sent" : "pending"}>
            {msg.text}
            {!msg.sent && <span className="spinner" />}
          </li>
        ))}
      </ul>
      
      <form action={handleSubmit}>
        <input name="message" type="text" required placeholder="Type a message..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

The rollback behavior is what makes this hook worth it: if sendMessage throws, the pending message disappears on its own, no manual cleanup needed. The catch is that reconciliation works only if the server eventually re-renders the component with the real data (in Next.js, via revalidatePath or a server action return). If the parent’s messages prop never updates, the optimistic item vanishes after the action resolves and the user sees it blink away. This pattern pairs naturally with the optimistic update pattern used across the stack.

flowchart diagram: User submits form

useFormStatus

useFormStatus gives a child component access to the enclosing form’s submission state: pending flag, submitted data, method, and action. The important limitation: it only works in components rendered inside the <form> element, not in the component that owns the form.

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? (
        <>
          <span className="spinner" /> Submitting...
        </>
      ) : (
        "Submit"
      )}
    </button>
  );
}

// Usage — SubmitButton is a child of the form
function ContactForm() {
  return (
    <form action={submitContact}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <SubmitButton />
    </form>
  );
}

It exists mainly so reusable submit buttons can disable themselves without prop drilling. Import it from react-dom, not react; importing it from react is a common migration mistake.

ref as a Prop

In React 19, ref is a regular prop on function components. forwardRef is no longer needed and is on the deprecation path.

// React 18 — required forwardRef
// const Input = React.forwardRef<HTMLInputElement, InputProps>(
//   (props, ref) => <input ref={ref} {...props} />
// );

// React 19 — ref is just a prop
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  ref?: React.Ref<HTMLInputElement>;
}

function Input({ label, ref, ...props }: InputProps) {
  return (
    <label>
      {label && <span className="label">{label}</span>}
      <input ref={ref} {...props} />
    </label>
  );
}

// Usage
function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  function focusInput() {
    inputRef.current?.focus();
  }
  
  return (
    <>
      <Input ref={inputRef} label="Name" type="text" />
      <button onClick={focusInput}>Focus input</button>
    </>
  );
}

forwardRef still works (existing code doesn’t break), but new components should take ref as a prop and declare it in the props interface, as in the example above.

Document Metadata

React 19 hoists <title>, <meta>, and <link> rendered inside components into the document <head>. That means a leaf component can declare its own SEO tags without a dedicated head-management library like react-helmet.

function BlogPost({ post }: { post: { title: string; description: string } }) {
  return (
    <article>
      {/* Document metadata — hoisted to <head> */}
      <title>{post.title}</title>
      <meta name="description" content={post.description} />
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.description} />
      <link rel="canonical" href={`https://stackpractices.com/blog/${post.slug}`} />
      
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Hoisting deduplicates tags by key, so a child can override a parent’s <meta name="description">; the deepest rendered tag wins. Tags rendered by the framework (like Next.js metadata exports) coexist fine; React only manages the ones it rendered itself.

Asset Loading

React 19 integrates with the platform’s resource-preloading APIs: <link rel="preload"> rendered in a component is hoisted to the head, and render can suspend while font and stylesheet assets resolve — so the browser doesn’t paint unstyled content.

// React 19 suspends while assets load
import { Suspense } from "react";

function ProfilePhoto({ src }: { src: string }) {
  return (
    <img src={src} alt="Profile" loading="lazy" />
  );
}

// preload assets
function App() {
  return (
    <>
      {/* Preload fonts and images */}
      <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" />
      <link rel="preload" href="/images/hero.webp" as="image" />
      
      <Suspense fallback={<div className="skeleton" />}>
        <ProfilePhoto src="/api/avatar/123" />
      </Suspense>
    </>
  );
}

For programmatic control there are also the preload, preinit, and prefetchDNS APIs exported from react-dom, useful when the asset isn’t known until runtime:

import { preload, preinit } from "react-dom";

preload("/fonts/inter.woff2", { as: "font", type: "font/woff2" });
preinit("/styles/editor.css", { as: "style" });

This matters most for Largest Contentful Paint: declaring the hero image and primary font as preload at the component that needs them keeps the hint colocated with the usage instead of buried in a global template. For the metrics side of this, the Web Performance and Core Web Vitals guide covers how preloading interacts with LCP.

React Compiler

The React Compiler analyzes your components at build time and memoizes values and functions automatically, making most hand-written useMemo and useCallback calls unnecessary.

// Before — manual memoization
function ProductList({ products }: { products: Product[] }) {
  const sorted = useMemo(
    () => products.sort((a, b) => a.price - b.price),
    [products]
  );
  
  const handleClick = useCallback((id: string) => {
    addToCart(id);
  }, []);
  
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id} onClick={() => handleClick(p.id)}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

// After — React Compiler handles memoization
function ProductList({ products }: { products: Product[] }) {
  const sorted = products.sort((a, b) => a.price - b.price);
  
  function handleClick(id: string) {
    addToCart(id);
  }
  
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id} onClick={() => handleClick(p.id)}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

The Compiler is not a tsconfig flag; it’s a Babel plugin (or framework integration) that you install and enable in your build pipeline:

# Install the compiler plugin and its ESLint companion
npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler
// babel.config.json
{
  "plugins": ["babel-plugin-react-compiler"]
}
// eslint.config.js — recommended to catch code the compiler can't optimize
// {
//   plugins: ["react-compiler"],
//   rules: { "react-compiler/react-compiler": "error" }
// }

Two honest caveats. First, the compiler only optimizes components that follow the rules of hooks; conditional hook calls or mutations during render get skipped or surface as ESLint errors. Second, it doesn’t eliminate every manual memo: expensive work in non-component code, or referential-stability requirements imposed by third-party libraries, may still need useMemo. Run it on a codebase for a while before deleting every manual optimization; measure rather than assume.

Migration to React 19

Upgrading is mostly mechanical, but a few removals will bite older codebases:

# Upgrade
npm install react@19 react-dom@19

# Update types
npm install -D @types/react@19 @types/react-dom@19

# Automated codemods (official React codemod recipes)
npx codemod react/19/migration-recipe

# Type-level migrations
npx types-react-codemod@latest preset-19 .
// Breaking changes to watch for:
// 1. ref is now a prop — forwardRef is deprecated (still works)
// 2. defaultProps removed for function components
// 3. string refs removed
// 4. propTypes removed
// 5. Legacy context (contextTypes / childContextTypes) removed
// 6. Module pattern factories and react-test-renderer deprecated

// Before: defaultProps
// function Button({ size = "medium" }) { ... }
// Button.defaultProps = { size: "medium" };

// After: default parameters
function Button({ size = "medium" }: { size?: string }) {
  return <button className={`btn btn-${size}`} />;
}

The migration recipe codemod covers the mechanical parts (string refs, forwardRef wrappers, removed APIs), but read the diff before committing; codemods can’t always tell a deliberate pattern from a removable one. If your codebase uses propTypes or legacy context heavily, budget time for manual work those codemods can’t do. For teams that want a second opinion on what to modernize, the React performance guide also covers which of these patterns hurt render performance today.

Best Practices

  • Draw the server/client boundary deliberately. Put "use client" as far down the tree as possible. A page-level directive turns your entire subtree into client JavaScript and throws away the bundle-size benefit.
  • Let forms carry their own state. Combine useActionState + useFormStatus instead of lifting isSubmitting into a parent; the form owns its lifecycle.
  • Pair useOptimistic with a revalidation path. The optimistic value is ephemeral; it only feels stable when the server re-renders the component with confirmed data.
  • Enable the Compiler’s ESLint rule first. eslint-plugin-react-compiler flags code the compiler can’t optimize; fixing those warnings unlocks the real gains.
  • Preload what the page actually paints. Fonts and the hero image benefit; preloading everything just competes with the HTML fetch.

Common Mistakes

  • Creating the promise inside the use() consumer. use(getUser(id)) in render fires a request per render. Create the promise in the parent or memoize it.
  • Passing functions or Dates through the server/client boundary. Only JSON-serializable values cross. Use server actions for behavior.
  • Putting useFormStatus in the form’s parent. It reads the nearest enclosing <form>; in the parent it returns pending: false forever.
  • Adding "reactCompiler": true to tsconfig. The compiler is a Babel plugin, not a TypeScript option; the flag does nothing.
  • Expecting optimistic state to persist after the action resolves. Without revalidatePath or fresh server data, the optimistic item silently disappears.
  • Trusting action inputs. Server actions are public endpoints, so validate FormData server-side, always.

See Also

Frequently Asked Questions

What is the difference between server components and client components?

Server Components execute on the server during SSR or at build time. They can access databases, file systems, and server-only libraries, and they ship no JavaScript to the client. Client Components run in the browser and can use state, effects, event handlers, and browser APIs. A server component can render a client component and pass it serializable props; a client component can't import a server component, though it can call server actions.

When should I use the use() hook instead of useEffect?

Reach for use() when you're unwrapping a promise and want Suspense to own the loading state. Keep useEffect for side effects that need cleanup — subscriptions, listeners, timers — or for fetches that must re-run on every mount. use() can be called conditionally, which is handy for context reads; useEffect can't.

Do I still need useMemo and useCallback with the React Compiler?

Usually not inside components: the compiler memoizes based on actual dependencies. Keep manual memoization for work outside components and for cases where a third-party library demands referential stability. Measure after enabling it; remove manual memos gradually rather than in one sweep.

How do actions differ from onSubmit handlers?

Actions attach to the form's native action attribute, get pending state and form reset for free, and (with a framework) work without JavaScript via progressive enhancement. onSubmit is a client-only event handler: fine for validation or intercepting a submission, but it can't submit without JS and requires manual state management.

Can I use React 19 features without a framework?

Partially. use(), useActionState, useOptimistic, useFormStatus, ref-as-prop, and the React Compiler all work in a plain client-side app. Server components and server actions need a framework with RSC support (Next.js, Remix, or a custom setup) because they require server-side rendering infrastructure.

How do I migrate from forwardRef to ref-as-prop?

Remove the forwardRef wrapper, add ref to the props interface (ref?: React.Ref<T>), and destructure it like any other prop. The react/19/migration-recipe codemod automates most of it. Existing forwardRef components keep working, so you can migrate file by file.