Topics
v5.0.0
On this page · sections

React Performance

Virtualization renders only visible rows — React and DOM work scale with viewport size, not dataset size. A render budget is the max main-thread work allowed per user input before feedback feels broken. Children-as-props passes pre-built JSX so React can skip reconciling a subtree when the parent re-renders.

TechniqueWhen to use
VirtualizationLong lists — O(viewport) DOM nodes
useTransitionDefer state updates you initiate (filter, tab switch)
useDeferredValueDefer rendering from a fast-changing prop in expensive child
Stable children elementHeavy subtree isolated from hot parent state
Server/typeaheadMove O(n) filter off main thread

Why virtualize

Definition

'use client'; import { useMemo, useState } from 'react'; const ROW_HEIGHT = 48; const VIEWPORT_HEIGHT = 400; function FixedWindowList({ count }: { count: number }) { const [scrollTop, setScrollTop] = useState(0); const { start, end } = useMemo(() => { const startIndex = Math.floor(scrollTop / ROW_HEIGHT); const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT); const endIndex = Math.min(count, startIndex + visibleCount + 1); return { start: startIndex, end: endIndex }; }, [scrollTop, count]); const items = []; for (let i = start; i < end; i++) { items.push( <div key={i} style={{ position: 'absolute', top: i * ROW_HEIGHT, height: ROW_HEIGHT, width: '100%' }}> Row {i} </div>, ); } return ( <div style={{ height: VIEWPORT_HEIGHT, overflow: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}> <div style={{ height: count * ROW_HEIGHT, position: 'relative' }}>{items}</div> </div> ); }
scroll container → overflow: auto, fixed height inner spacer → height: itemCount × rowHeight absolute rows → top: index × rowHeight or translateY(start) window → [startIndex, endIndex] from scrollTop

Skip virtualization below ~100 simple rows when profiling shows no jank.

Interview: Virtualization fixes DOM/React cost — fetch latency is a separate problem.

TanStack Virtual pattern

Real-world example

'use client'; import { useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; function TanStackList({ ids }: { ids: string[] }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: ids.length, getScrollElement: () => parentRef.current, estimateSize: () => 48, overscan: 5, }); return ( <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}> <div style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}> {virtualizer.getVirtualItems().map((item) => ( <div key={item.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: item.size, transform: `translateY(${item.start}px)`, }} > Product {ids[item.index]} </div> ))} </div> </div> ); }
react-window → FixedSizeList; mature render prop { index, style } TanStack Virtual → useVirtualizer hook; flexible scroll element; strong variable height react-virtuoso → higher-level auto variable height — larger bundle

Map virtualItems, not all ids. Parent needs defined height and overflow: auto.

Render budget and INP

Definition

Render budget → Max JS work per interaction before feedback breaks (~50ms/frame) INP → Input to next paint; good ≤200ms field p75 Long task → Sync block >50ms — blocks input handling
'use client'; function Search({ items }: { items: string[] }) { const [query, setQuery] = useState(''); const [filtered, setFiltered] = useState(items); const [isPending, startTransition] = useTransition(); function onChange(value: string) { setQuery(value); // urgent — input stays responsive startTransition(() => { setFiltered(items.filter((item) => item.toLowerCase().includes(value.toLowerCase()), )); }); } return ( <div> <input value={query} onChange={(e) => onChange(e.target.value)} /> {isPending && <span aria-live="polite">Updating…</span>} <ul>{filtered.slice(0, 100).map((item) => <li key={item}>{item}</li>)}</ul> </div> ); }

React 18 concurrent features schedule work — they do not make expensive algorithms free. A 200ms sync loop inside a transition is still a 200ms long task.

Interview: INP replaced FID — captures worst interaction latency for the whole session, not just first input after load.

useTransition vs useDeferredValue

Definition

useTransition → You initiate setState — wrap in startTransition useDeferredValue → Defer rendering a value derived from props/state you read but don't own Neither → Replaces algorithmic optimization, virtualization, or server search
const HeavyChart = memo(function HeavyChart({ query }: { query: string }) { return <div>{/* expensive visualization */}</div>; }); function DeferredSearch({ query }: { query: string }) { const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <div style={{ opacity: isStale ? 0.6 : 1 }}> <HeavyChart query={deferredQuery} /> </div> ); }

Rule: own the setState → useTransition. Consume fast-changing prop in expensive child → useDeferredValue.

Long tasks and yielding

Definition

React will not slice your for loop — chunk with setTimeout(0), use Web Workers for heavy CPU, or move O(n) work to server/typeahead. Cancel in-flight chunk chains when query changes.

Typeahead fix plan (50k SKUs)

Real-world example

Fix priority order:

1. Measure — Profiler + field INP; confirm long task in handler 2. Server typeahead — debounce 150ms, return top 20 3. Virtualize results — react-window; constant DOM 4. Transition for non-critical highlights only 5. CI gate — Playwright trace; no long task >100ms during typeahead
function Typeahead() { const [query, setQuery] = useState(''); const [debounced, setDebounced] = useState(''); useEffect(() => { const id = setTimeout(() => setDebounced(query), 150); return () => clearTimeout(id); }, [query]); const { data = [] } = useQuery({ queryKey: ['sku-search', debounced], queryFn: () => fetch(`/api/search?q=${debounced}`).then((r) => r.json()), enabled: debounced.length >= 2, }); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} /> <List height={400} itemCount={data.length} itemSize={32} width="100%">{Row}</List> </> ); }

Interview: Memoizing a Row component does not help if the parent still filters 50k items synchronously. Fix algorithm and IO before memo.

Element identity and composition

Definition

React reconciles by element reference. Same type + key + props ref → skip subtree work.

'use client'; import { useState, type ReactNode } from 'react'; function ExpensiveList() { return ( <ul> {Array.from({ length: 5000 }, (_, i) => ( <li key={i}>Row {i}</li> ))} </ul> ); } // ❌ Inline — new element every parent render function BadShell() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount((c) => c + 1)}>{count}</button> <ExpensiveList /> </div> ); } // ✅ Lifted element — stable reference const stableList = <ExpensiveList />; function GoodShell({ children }: { children: ReactNode }) { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount((c) => c + 1)}>{count}</button> {children} </div> ); } // <GoodShell>{stableList}</GoodShell> — counter clicks skip ExpensiveList

children is just a prop. Pre-created elements from a stable scope survive parent re-renders.

Interview: Same element reference → React skips subtree reconcile. Inline JSX in a hot parent creates a new element object every render.

Composition vs React.memo

Definition

React.memo compares props shallowly — inline callbacks and new objects break it. Composition moves volatile state outside the subtree.

React.memo → opt-out via shallow prop compare; breaks on inline callbacks Composition → opt-out by not being in the hot re-render path Rule → restructure first, memo second

Pass elements, not component types — rendering <Content /> inside the shell recreates the element every parent render. Portal changes DOM placement, not automatic memo — keep the portaled subtree tiny.

Senior Interview Summary

What to say

Virtualization is O(viewport) DOM — spacer height sets scrollbar, absolute rows fill the window. Render budgets translate Core Web Vitals into engineering constraints. INP captures post-load interaction jank. Pre-built JSX passed as children from a stable scope lets React skip subtree reconciliation. Restructure before memoizing.

Performance checklist

Virtualization → map virtualItems only; overscan 5–8 INP threshold → good ≤200ms field p75 useTransition → defer updates you initiate useDeferredValue → defer expensive child from fast prop Fix order → algorithm → IO → schedule → memo Element identity → lifted children skip reconcile

Best mental model

Spacer → total scroll height = sum of row heights Transition → "deprioritize, not free" Composition → "volatile state outside heavy subtree" Long task → "browser can't handle input while I'm running"

Practical rule

< 100 simple rows, no jank → skip virtualization Typeahead → server top-k + debounce + virtualization Dashboard shell → stable grid children from layout + counter island CI gate → scripted interaction; assert no long task >100ms

Last updated on