Module 4 · React · Deep Dive

Performance, Refs & React 19

The capstone of React: why components re-render, how to make the slow ones fast, how to reach the DOM when you must, how to stop one broken component from white-screening the app — and the React 19 concurrent features that keep DocChat's chat fast and optimistic.

IntermediateAdvancedBuild

Why this matters DocChat's chat panel is the part users live in. It has a heavy message list that must not re-render on every keystroke, a send box that should feel instant — the message appearing before FastAPI confirms it — and a model answer that streams in while the user keeps typing. Get the re-render model wrong and the whole chat janks on every letter. This lesson is the toolkit — useMemo, React.memo, useRef, error boundaries, Suspense, and React 19's useTransition and useOptimistic — that turns a correct UI into a fast one. It's also the densest interview territory in the whole React track.
In this lesson
  1. The re-render model
  2. useMemo & useCallback
  3. React.memo
  4. useRef: two jobs
  5. Keys, deeper
  6. Error boundaries
  7. Suspense & lazy
  8. React 19 concurrent features
  9. Check yourself

1 · The re-render model

Before you optimise anything, you have to know what you're optimising. A component re-renders — React calls your function again — for exactly three reasons:

That last one surprises people. Type one letter into a search box at the top of the page and, unless you intervene, every component below it runs again.

Here is the single most important idea in this lesson: a re-render is not a DOM update. When React re-runs your function it produces a new tree of virtual elements, then reconciles it against the previous one and touches the real DOM only where they differ. Re-rendering is cheap-ish JavaScript; DOM mutation is the expensive part React is already minimising for you.

jQuery bridge: in jQuery you mutate the DOM by hand — $("#list").append(...) — so every change you write is a DOM write, and forgetting one leaves the UI stale. React flips it: you describe the UI declaratively for the current state, React figures out the minimal DOM edits. "Re-render" means "recompute the description", not "rewrite the page".
Measure before you optimise Most re-renders are harmless — React is fast. Don't sprinkle useMemo everywhere on instinct. Open React DevTools → Profiler, record an interaction, and find the component that's actually slow. Optimise that one. Optimising a component that was never the bottleneck just adds code and cost.

2 · useMemo & useCallback

Since every re-render re-runs every line of your function, an expensive computation runs every time too — even when its inputs didn't change. useMemo caches the result and only recomputes when a dependency changes.

const sorted = useMemo(() => {
  // pretend this is heavy: filter + sort 5,000 messages
  return messages
    .filter((m) => m.text.includes(query))
    .sort((a, b) => b.time - a.time);
}, [messages, query]);   // recompute only when messages or query change

Same dependency-array idea as useEffect, but instead of running a side effect it returns a cached value. If messages and query are unchanged across a render, you get the previous result back for free.

useCallback is the same tool for functions. Every render creates brand-new function objects, so a callback you pass to a child is a different reference each time. useCallback hands back a stable function identity as long as its deps don't change:

const handleSend = useCallback((text) => {
  sendMessage(chatId, text);
}, [chatId]);   // same function object until chatId changes

On its own, a stable function does nothing for performance — its whole point is to keep a memoised child (next section) from seeing a "new" prop on every parent render. useCallback(fn, deps) is just useMemo(() => fn, deps) with nicer ergonomics.

When NOT to use them Both hooks have a cost: they run code, allocate a dependency array, and compare it every render. For a cheap computation or a callback passed to a plain DOM element, that overhead is more than the work you're saving — this is the textbook premature optimisation. Reach for them only when (a) the computation is genuinely expensive, or (b) you're passing the value/function to a React.memo child and need a stable reference. Otherwise leave them out.
Interview: "What's the difference between useMemo and useCallback?" useMemo caches the return value of a function; useCallback caches the function itself. Both take a dependency array and only refresh when it changes. You use them to avoid recomputing expensive values and to keep stable references for memoised children — not as a blanket "make it faster" sprinkle.

3 · React.memo

Recall the gotcha from §1: a parent re-render re-renders every child by default. React.memo wraps a component so it skips re-rendering when its props are unchanged (a shallow comparison of each prop). It's how you stop DocChat's heavy message list from re-running every time the send box's input state ticks.

const MessageList = React.memo(function MessageList({ messages, onReply }) {
  // re-runs only when `messages` or `onReply` actually change
  return (
    <ul>{messages.map((m) => <Message key={m.id} {...m} />)}</ul>
  );
});

But the shallow prop check is reference-based, so React.memo only pays off when the props are stable. If the parent passes a fresh array or a fresh inline function every render, the memo sees "new prop" and re-renders anyway — defeating the point. That's the pairing:

function Chat() {
  const [draft, setDraft] = useState("");   // changes on every keystroke
  const [messages, setMessages] = useState([]);

  const onReply = useCallback((id) => setReplyTo(id), []);

  return (
    <>
      <MessageList messages={messages} onReply={onReply} />   // skipped while typing
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
    </>
  );
}

Now typing in the input updates draft and re-renders Chat, but MessageList's props (messages, the memoised onReply) are unchanged, so React skips the whole expensive list. That's the DocChat win.

4 · useRef: two jobs

useRef returns a mutable object with a single .current property that survives re-renders. Crucially, changing .current does not trigger a re-render. It has two distinct uses.

Job A imperative DOM access

Sometimes you genuinely need the real DOM node — to focus an input, measure it, or scroll a container. Attach a ref to a JSX element via the ref attribute and React fills in .current with the DOM node after render.

function ChatPanel({ messages }) {
  const bottomRef = useRef(null);

  // after new messages render, scroll the chat to the bottom
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  return (
    <div className="chat">
      {messages.map((m) => <Message key={m.id} {...m} />)}
      <div ref={bottomRef} />   // sentinel we scroll to
    </div>
  );
}
jQuery bridge: this is the rare case where React lets you do what jQuery always did — touch the node directly ($(el).focus()ref.current.focus()). The difference: in React it's the deliberate exception, not the default. You reach for a ref only when declarative JSX can't express the thing (focus, scroll, measure, media playback).

Job B a mutable value that doesn't re-render

A ref is also just a box for any value you want to keep between renders without the value being part of the rendered output. A timer id, the previous value of a prop, whether the user has scrolled — anything you'd otherwise stuff into state but that the UI doesn't display.

const intervalRef = useRef(null);

useEffect(() => {
  intervalRef.current = setInterval(poll, 3000);   // no re-render on assignment
  return () => clearInterval(intervalRef.current);
}, []);
Interview answer · useRef vs useState Both persist a value across re-renders. The split is who watches it. useState is for values the UI displays — changing it triggers a re-render so the screen updates. useRef is for values the UI does not display — changing .current is silent, no re-render. Rule of thumb: if rendering reads it, use state; if only effects and handlers read it, use a ref. And never read or write .current during render — refs are for effects and event handlers, because mutating one during render is a side effect that breaks React's purity.

5 · Keys, deeper

You learned in 4.2 that every item in a .map needs a key. Here's why the choice matters so much. A key is how React decides component identity across renders — "is this the same item as last time, or a different one?" Use the array index and that identity is the position, not the item. Insert, delete, or reorder, and the positions stay 0,1,2 while the data behind them shifts — so React keeps the wrong component's state attached to the wrong row.

// ❌ key=index: delete the first message and every row's
// internal state (a half-typed edit, an open menu) shifts up one
{messages.map((m, i) => <Message key={i} {...m} />)}

// ✅ key=stable id: React tracks each message correctly through
// inserts, deletes, and reorders
{messages.map((m) => <Message key={m.id} {...m} />)}

Concrete failure: a chat where each Message has its own "edit" textarea. Delete message #1 with index keys and React, seeing key=0 still present, reuses that component instance — so the text you were editing in the old #1 suddenly appears attached to what's now the top message. With a stable m.id, React knows #1 is gone and unmounts it cleanly. Index keys are only safe for a static list that never reorders, inserts, or deletes.

6 · Error boundaries

If a component throws during render, React unmounts the entire tree above it — a blank white screen. One malformed message shouldn't kill all of DocChat. An error boundary is a component that catches render errors from its children and shows a fallback UI instead.

This is the one place class components still legitimately exist in 2026: React has no hook equivalent for the error-catching lifecycle methods, so an error boundary must be a class (or you use a library that wraps one for you).

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };          // flip to the fallback
  }
  componentDidCatch(error, info) {
    logToServer(error, info);                // report it
  }
  render() {
    if (this.state.hasError) return <p>Something broke in this panel.</p>;
    return this.props.children;
  }
}

// wrap the risky subtree — the rest of the app stays alive
<ErrorBoundary>
  <MessageList messages={messages} />
</ErrorBoundary>

In practice most teams don't hand-write that class. The react-error-boundary package gives you a hooks-friendly <ErrorBoundary FallbackComponent={...} onReset={...}> with reset and retry built in — the ergonomic, idiomatic choice. Mention it in interviews; writing the class by hand also shows you understand what the package wraps.

What boundaries do NOT catch Error boundaries catch errors thrown during rendering (and in lifecycle methods and constructors). They do not catch errors in event handlers, in async code (a fetch .catch, a setTimeout), or during server-side rendering. For those you still use ordinary try/catch and state. A boundary is a net under the render, not under everything.

7 · Suspense & lazy

The three-state fetch from 4.2 — manual loading/error/data flags — works, but it scatters loading logic through every component. <Suspense> lets you declare the loading state once, around a subtree: while anything inside is "not ready", React shows the fallback.

<Suspense fallback={<Spinner />}>
  <ChatHistory chatId={id} />   // suspends until its data is ready
</Suspense>

You rarely wire Suspense to fetch by hand — a Suspense-aware data layer does it for you. In a framework (Next.js) you fetch in a Server Component and the result streams in; in a client app you use TanStack Query or SWR in their Suspense mode. The component just reads the data as if it were already there, and Suspense handles the "not yet" — far cleaner than threading a loading boolean everywhere.

Suspense also powers code splitting. React.lazy defers loading a component's JavaScript until it's first rendered, and Suspense shows the fallback while that chunk downloads:

const Settings = React.lazy(() => import("./Settings"));

<Suspense fallback={<Spinner />}>
  <Settings />   // its bundle loads on demand, not on first paint
</Suspense>

So DocChat's initial load stays small — the heavy settings panel only ships its code when the user actually opens it.

jQuery bridge: the old way was one giant app.js the browser parsed before anything showed. React.lazy + Suspense is the declarative version of "load this part later" — you describe the boundary and the fallback, React handles the async loading and swap.

8 · React 19 concurrent features

React 19 (current in 2026) ships concurrent features: ways to tell React that some updates are less urgent than others, so an urgent one (the user typing) never gets blocked behind a slow one (filtering 5,000 messages).

useTransition keep typing responsive

Wrap a non-urgent state update in a transition and React lets urgent updates interrupt it. The classic case: a search box that filters a huge list. The keystroke is urgent; the re-filter is not.

const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");

function onChange(e) {
  setQuery(e.target.value);                 // urgent: input updates instantly
  startTransition(() => {
    setResults(filterHugeList(e.target.value));  // non-urgent: can be interrupted
  });
}
// isPending lets you show a subtle "updating…" hint

useDeferredValue lag a value on purpose

Same goal from the consuming side. useDeferredValue hands you a copy of a value that "lags behind" during heavy work, so the expensive child renders with the slightly-stale value while the input stays snappy:

const deferredQuery = useDeferredValue(query);
// pass deferredQuery to the heavy list; query stays live in the input
<MessageList query={deferredQuery} />

useOptimistic show it before the server confirms

The DocChat win. When the user sends a message you want it to appear instantly, then reconcile when FastAPI confirms — instead of waiting on a round-trip. useOptimistic gives you a temporary, optimistic state that React automatically reverts to the real state once the async action resolves.

const [optimisticMessages, addOptimistic] = useOptimistic(
  messages,
  (current, newText) => [...current, { id: "temp", text: newText, pending: true }]
);

async function send(text) {
  addOptimistic(text);                       // message shows immediately, marked pending
  await sendToServer(text);                  // real send
  // when messages state updates, the optimistic entry is replaced
}

// render optimisticMessages, not messages

The user sees their message the instant they hit enter, faintly greyed while pending; if the send fails, React drops the optimistic entry and you surface an error. That's the snappy, modern chat feel — built in, no manual juggling.

The React Compiler — auto-memoisation React 19 also introduced the React Compiler, which analyses your components at build time and inserts memoisation automatically — increasingly making manual useMemo, useCallback, and even React.memo unnecessary in everyday code. So why learn them? Because you'll work in codebases that predate the compiler, you'll debug cases where it can't help, and interviewers will ask you to explain memoisation by hand. The compiler removes the chore; it doesn't remove the need to understand the model.
Interview answer · "How do you optimise a slow React list?" Walk it as a process, not a trick. 1) Measure with the DevTools Profiler — confirm the list is actually the bottleneck and find what triggers its re-renders. 2) Stabilise the inputs: stable key={item.id} (never the index), and useMemo/useCallback for any object or function props. 3) React.memo the row and the list so unchanged props skip re-rendering — which only works once the props are stable. 4) If the list is genuinely huge, virtualise it (TanStack Virtual / react-window) so only visible rows mount. 5) For typing-driven filters, wrap the heavy update in useTransition or use useDeferredValue so input stays responsive. Mention React 19's compiler auto-memoises much of steps 2–3 — but you still reason about it the same way.

9 · Flashcards

Flip each card, answer aloud, then check. Active recall beats re-reading.

The three triggers for a re-render?
Own state changed, props changed, or the parent re-rendered.
click to flip
Re-render vs DOM update?
A re-render re-runs your function to build a new tree; React reconciles it and touches the DOM only where it differs. They're not the same thing.
click to flip
useMemo vs useCallback?
useMemo caches a value; useCallback caches a function. Both keyed by a deps array.
click to flip
What pairs with React.memo?
Stable props — useMemo for object/array props, useCallback for function props. Without stable references the memo re-renders anyway.
click to flip
useRef's two jobs?
(a) imperative DOM access (focus, scroll); (b) a mutable value that survives re-renders without triggering one.
click to flip
Why is key=index dangerous?
The key becomes the position, not the item — on insert/delete/reorder React keeps the wrong component's state on the wrong row.
click to flip
What can't error boundaries catch?
Event-handler errors, async errors, and SSR errors. They only catch errors thrown during render.
click to flip
What does useOptimistic do?
Shows a temporary optimistic state instantly, then reverts to the real state when the async action resolves — instant-feeling chat sends.
click to flip

Drill memoise the chat

DocChat's Chat re-renders MessageList (5,000 rows) on every keystroke in the send box. Without running it, list the three changes that stop that, and say why each is necessary on its own.

Show the answer

1) Wrap MessageList in React.memo so it skips re-rendering when its props are unchanged — this is what actually prevents the re-render while typing.

2) useCallback the onReply handler passed to the list. Without it, a fresh function object every render makes the memo see a "new" prop and re-render anyway.

3) useMemo the messages array (or otherwise keep its reference stable) if it's derived/filtered inline in Chat — a new array each render also defeats the memo.

All three are needed because React.memo does a shallow reference check: it only helps once every prop reference is stable across the keystroke re-render. Bonus: React 19's compiler would insert much of this automatically — but you still have to reason about it for interviews and pre-compiler code.

10 · Check yourself

Answer from memory — retrieval is what turns "I read it" into "I can ship it".

Recall quiz

A re-render in React directly means what?

When does React.memo actually help?

Changing a ref's .current value does what?

Which error does a boundary NOT catch?

Which hook shows a message before confirm?

Primary source ⭐ react.dev — useMemo reference, the authoritative 2026-current explanation of caching, when to skip it, and how it pairs with React.memo. For the concurrent features, read useOptimistic and the React 19 release notes (Actions, the React Compiler, and the new hooks).