Intermediate

Modern React

How React decides what to update, how to keep an app fast, and the newer features added in React 19.

How rendering works

Updating the screen happens in two steps:

  1. Render: React calls your component functions to work out what the UI should look like now.
  2. Commit: React compares the result with the previous one and changes only the parts of the real DOM that differ.

A component renders again when its state changes, when the context it reads changes, or when its parent renders again. The last point surprises people. By default, re-rendering a parent re-renders all of its children, even if their props haven't changed.

This is usually fine, because rendering is fast and React only changes the DOM where something is different. Optimize only when you can see or measure a slowdown.

State belongs to a position in the tree

React keeps a component's state for as long as the same component stays in the same position in the tree. Rendering a different component type there, or changing its key, discards the state:

{isEditing ? <EditForm /> : <Preview />}   // switching types resets state

<Chat key={contactId} contact={contact} />  // new key = fresh chat state

Performance tools

ToolWhat it does
memo(Component)Skips re-rendering a component when its props haven't changed.
useMemo(fn, deps)Caches the result of an expensive calculation between renders.
useCallback(fn, deps)Caches a function, so a memoized child doesn't see a "new" prop each render.
useTransitionMarks an update as non-urgent, so typing and clicking stay responsive while it renders.
import { memo, useMemo, useCallback } from "react";

const ProductList = memo(function ProductList({ items, onSelect }) {
  // only re-renders when `items` or `onSelect` change
});

function Shop({ products, filter }) {
  const visible = useMemo(
    () => products.filter(p => p.category === filter),
    [products, filter]
  );

  const handleSelect = useCallback(id => {
    console.log("selected", id);
  }, []);

  return <ProductList items={visible} onSelect={handleSelect} />;
}

React Compiler. The official React Compiler is a build-time plugin that adds this memoization for you automatically. In projects that use it, you rarely need to write memo, useMemo or useCallback by hand. It's worth understanding them anyway, because you will see them in existing code.

Measure first

Use the Profiler tab in React Developer Tools to see which components render and how long they take. Common real-world fixes include rendering long lists with virtualization, moving state closer to where it's used, and splitting large components.

Code splitting with lazy & Suspense

Large apps don't need to download everything up front. lazy loads a component only when it is first rendered, and Suspense shows a fallback while it loads:

import { lazy, Suspense } from "react";

const AdminPanel = lazy(() => import("./AdminPanel.jsx"));

function App({ isAdmin }) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {isAdmin && <AdminPanel />}
    </Suspense>
  );
}

Error boundaries

If a component throws an error while rendering, React removes the whole tree from the screen. An error boundary catches the error and shows a fallback instead. You can only write error boundaries as class components, so most people use the small react-error-boundary package:

import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<p>Something went wrong.</p>}>
  <Dashboard />
</ErrorBoundary>

What's new in React 19

ref is a normal prop

Function components can now accept ref like any other prop, so you no longer need forwardRef:

function TextInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

Actions and form handling

You can pass a function, including an async one, to a form's action prop. React gives it the form's data. The useActionState hook tracks the result and whether the action is still running:

import { useActionState } from "react";

async function subscribe(prevState, formData) {
  const res = await fetch("/api/subscribe", {
    method: "POST",
    body: formData,
  });
  return res.ok
    ? { message: "Thanks for subscribing!" }
    : { error: "Something went wrong. Please try again." };
}

function Newsletter() {
  const [state, formAction, isPending] = useActionState(subscribe, {});

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <button disabled={isPending}>
        {isPending ? "Sending…" : "Subscribe"}
      </button>
      {state.message && <p>{state.message}</p>}
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

You don't need onSubmit, preventDefault() or separate loading and error state.

Optimistic updates

useOptimistic shows the expected result immediately while the real request is still in progress. If the request fails, React reverts to the real state:

import { useOptimistic } from "react";

function LikeButton({ likes, onLike }) {
  const [optimisticLikes, addLike] = useOptimistic(
    likes,
    (current, amount) => current + amount
  );

  async function like() {
    addLike(1);        // screen updates instantly
    await onLike();    // then the server confirms
  }

  return (
    <form action={like}>
      <button>♥ {optimisticLikes}</button>
    </form>
  );
}

The use() API

use reads the value of a promise or a context. Unlike the other hooks, you can call it inside conditions. When it reads a promise, the nearest Suspense boundary shows its fallback until the promise resolves:

import { use, Suspense } from "react";

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return comments.map(c => <p key={c.id}>{c.text}</p>);
}

<Suspense fallback={<p>Loading comments…</p>}>
  <Comments commentsPromise={commentsPromise} />
</Suspense>

Create the promise outside the component that reads it, for example in a parent, a router loader or a data library. A promise created during render would be recreated on every render.

Where to go from here