Back to Notes

React Performance & Reconciliation

Virtual DOM & Reconciliation

React maintains a virtual (in-memory) representation of the UI tree; on a state change, it builds a new virtual tree and diffs it against the previous one, applying only the minimal real-DOM mutations needed — real DOM writes are the expensive part, and batching/minimizing them is the entire point of the virtual DOM.

Diffing rules: elements of a different type at the same position → the old subtree is unmounted and a new one mounted from scratch (state is lost). Elements of the same type → props are updated in place (state is preserved).

The key Prop — Why It Matters, Concretely

{items.map(item => <Item key={item.id} {...item} />)}   // stable, unique ID — correct
{items.map((item, i) => <Item key={i} {...item} />)}    // array index — dangerous for dynamic lists

key tells React's diffing algorithm which list items are "the same" across renders — with a stable ID, React correctly matches items even if the list is reordered, and preserves each item's own component state correctly. Using array index as key breaks this the moment the list is reordered, filtered, or has items inserted/removed anywhere but the end: React matches by position, not identity, so item state (an open dropdown, a text input's typed value) can silently attach to the wrong visual row after a reorder — a genuinely subtle bug, not just a lint warning.

React.memo

const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
  return <div>{/* expensive render */}</div>;
});

Skips re-rendering a component if its props are shallow-equal to the previous render's props. The common trap that defeats it: passing an inline object/array/function as a prop (<Child config={{ a: 1 }} />) creates a new reference every parent render, so the shallow-equality check always sees "different props" — React.memo alone doesn't help unless the prop values themselves are stable references, which is exactly what useMemo/useCallback (see [[React Hooks Deep-Dive]]) are for on the parent side.

Code Splitting

const Heavy = React.lazy(() => import('./Heavy'));
<Suspense fallback={<Spinner />}><Heavy /></Suspense>

Splits the bundle so a route/component's code downloads only when actually needed — the standard fix for a large initial bundle slowing down first load, especially for routes most users never visit in a given session.

Virtualization

For long lists (thousands of rows), rendering every item as a real DOM node is the actual bottleneck, not the data itself — libraries like react-window/react-virtual render only the currently-visible slice (plus a small buffer) and reuse a small pool of DOM nodes as the user scrolls, keeping DOM node count roughly constant regardless of total list length. This is the client-side analogue of the pagination reasoning in [[API Design Principles]] — don't materialize more than what's currently needed.

Performance Patterns Reference

ProblemFix
Too many DOM nodesVirtualization
Expensive re-rendersReact.memo + useMemo + useCallback (together — memo alone isn't enough if props aren't stable)
Large initial bundleCode splitting (React.lazy + route-based splits)
Slow imagesLazy loading (loading="lazy" or IntersectionObserver)
Duplicate API callsReact Query/SWR caching, or a manual request-dedup Map
Cumulative layout shiftSkeleton screens, fixed image dimensions reserved upfront

Interview Drills

  1. Why does using an array index as a key cause bugs specifically when a list is reordered, but not when items are only ever appended? — React matches elements across renders by their key, not by identity of the underlying data — if the list order changes, the same index now points at different underlying data, but React (seeing the same key at that position) treats it as "the same element updated," potentially carrying over stale component-internal state (like a focused input's typed value) onto the wrong row. Appending-only never reassigns which data an existing index refers to, so the bug doesn't surface in that specific case.
  2. React.memo is applied to a child component, but it still re-renders every time the parent re-renders. What's the likely cause? — The parent is passing an inline object, array, or function as a prop — a new reference is created on every parent render regardless of whether the underlying data changed, so React.memo's shallow prop comparison always reports "different" — wrapping that prop's creation in useMemo/useCallback on the parent side gives it a stable reference across renders where the underlying value hasn't changed.
  3. Why is virtualizing a long list a fundamentally different fix than React.memo for a slow-scrolling list?React.memo reduces re-render work for components whose props haven't changed, but doesn't reduce how many DOM nodes exist; virtualization directly limits DOM node count to roughly the visible viewport regardless of total list length — the actual bottleneck for very long lists is DOM size itself, not re-computation, which memoization alone doesn't address.

Cross-links

[[React Hooks Deep-Dive]] · [[API Design Principles]] · [[Caching & Redis]]