How would you optimize performance in a React app with large component trees?
Profile first. Then: split context so updates fan out narrowly, memoize expensive subtrees with stable prop refs, virtualize lists, lift hot state out of Context to a selector store, use `useDeferredValue` / `startTransition` for non-urgent updates, RSC for non-interactive parts, code split routes. Avoid the 'memoize everything' anti-pattern — most renders are cheap.
Large trees are not inherently slow — React reconciles fast. They become slow when state changes cause re-renders to cascade into expensive subtrees.
Step 1 — profile
React DevTools Profiler:
- Record a slow interaction.
- Look at the flamegraph — long bars = expensive renders.
- "Why did this render" → unexpected re-renders.
- Highlight Updates renders to visualize cascades.
Step 2 — diagnose by category
Cascade from parent state
Symptom: typing in a field re-renders the whole page. Fix: localize the field's state (it doesn't need to live at the root).
Context fan-out
Symptom: any state change re-renders every consumer. Fix: split context; move hot state out to a selector-based store (Zustand, Redux with useSelector).
Unstable prop references
Symptom: memo'd child re-renders even when data hasn't changed. Fix: useMemo / useCallback in the parent for the props passed to memo'd children.
Long lists
Symptom: 5k+ DOM nodes; scroll jank. Fix: virtualize.
Expensive derivations on every render
Symptom: filter/sort/map computed each render. Fix: useMemo with proper deps, or move to a Worker if heavy.
Step 3 — fix selectively
const Row = React.memo(({ item }) => /* ... */); // for genuinely heavy rows
const callback = useCallback((id) => ..., []); // stable ref for memo'd childOnly on the actual hot path. Memo on everything = wasted comparison cost.
Step 4 — deferred + transitions
const deferred = useDeferredValue(query); // expensive computation lags
startTransition(() => setLargeFilteredList(...)); // input stays responsiveStep 5 — structural changes
- RSC for non-interactive parts → less client JS.
- Code split per route / heavy feature → less initial bundle.
- Virtualize long lists.
- Move heavy compute to Workers (parse, search index, image processing).
Step 6 — measure delta
Re-profile. If the metric didn't improve, revert and look elsewhere.
Anti-patterns
- Sprinkling memo / useCallback before profiling.
- Wrapping cheap components in React.memo (the comparison cost adds up).
- Returning new object literals as memoized callback deps.
- "Optimizing" a 50-node tree.
Interview framing
"Profile first — React DevTools Profiler tells you which components rendered, why, and how long. Most renders are cheap; problems come from cascades into expensive subtrees. Diagnose by category: parent state cascading (localize), Context fan-out (split context, move hot state to a selector store), unstable prop refs defeating memo (stable refs via useMemo/useCallback in parents), long lists (virtualize), expensive derivations (useMemo or Worker). Then deferred updates / transitions for non-urgent work, RSC for non-interactive parts, code split per route. Measure delta. Don't memoize everything — comparison cost outweighs savings on cheap components."
Follow-up questions
- •When does memoization hurt?
- •How do you fix Context fan-out?
- •What's the biggest perf win you've shipped?
Common mistakes
- •Memoizing without profiling.
- •Context with frequent updates.
- •Ignoring structural fixes (virtualization, RSC).
Performance considerations
- •Structural fixes outperform micro-memo. Profile, fix, measure.
Edge cases
- •Strict Mode double-rendering in dev distorts numbers.
- •Production profiling differs from dev.
- •Memo + new prop refs.
Real-world examples
- •Slack desktop, Linear web, large dashboards.