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.
| Technique | When to use |
|---|---|
| Virtualization | Long lists — O(viewport) DOM nodes |
useTransition | Defer state updates you initiate (filter, tab switch) |
useDeferredValue | Defer rendering from a fast-changing prop in expensive child |
Stable children element | Heavy subtree isolated from hot parent state |
| Server/typeahead | Move 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 scrollTopSkip 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 bundleMap 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 searchconst 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 typeaheadfunction 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 ExpensiveListchildren 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 secondPass 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 reconcileBest 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