Module 4 · React · Deep Dive
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
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.
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".
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.
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.
React.memo child and need a stable reference. Otherwise leave them out.
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.
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:
React.memo on the child to skip unchanged renders,useMemo for any object/array prop so its reference is stable,useCallback for any function prop so its reference is stable.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.
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.
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).
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); }, []);
.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.
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.
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.
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.
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 giantapp.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.
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).
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
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} />
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.
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.
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.
Flip each card, answer aloud, then check. Active recall beats re-reading.
useMemo caches a value; useCallback caches a function. Both keyed by a deps array.useMemo for object/array props, useCallback for function props. Without stable references the memo re-renders anyway.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.
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.
Answer from memory — retrieval is what turns "I read it" into "I can ship it".
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?
React.memo. For the concurrent features, read useOptimistic and the React 19 release notes (Actions, the React Compiler, and the new hooks).