React performance optimization techniques
React performance optimization is a set of targeted techniques (memoization, code splitting, virtualization) that prevent unnecessary re-renders, skip expensive recalculations, and reduce initial bundle size in large apps.
Theory
TL;DR
- React re-renders every child when a parent's state changes; optimization adds a comparison gate to skip unchanged ones
React.memocaches a component's output;useMemocaches a computed value;useCallbackcaches a function reference- Profile first with React DevTools Profiler. State colocation and code splitting usually give bigger wins than adding
memoeverywhere - List with 200+ items → virtualization. Bundle over 500KB →
React.lazy. Handler prop causing child re-renders →useCallback+React.memo
Quick example
// Without optimization: Child re-renders on every count change
function Parent() {
const [count, setCount] = useState(0);
return <Child name="Item" count={count} />;
}
// With React.memo: Child skips re-render if props are unchanged (shallow compare)
const Child = React.memo(({ name, count }: { name: string; count: number }) => {
console.log(`${name} rendered`); // Only logs when props actually change
return <div>{name}: {count}</div>;
});Parent re-renders on every button click. The memoized Child only logs when name or count actually changes. Without React.memo, it logs every single time.
Why React re-renders by default
React's reconciliation algorithm re-renders the entire subtree below any component whose state or props change. For small trees, that is fast enough to ignore. For a dashboard with 50+ child components, it adds up quickly.
The optimization techniques do not change the algorithm. They add a shallow comparison gate before the component function runs. If props are the same reference or the same primitive value, React bails out before touching JSX or the DOM.
When to use each technique
- Parent re-renders often, child props stay stable →
React.memo - Expensive derived data: reduce, map, sort →
useMemo - Handler passed as prop to a memoized child →
useCallback - List with 200+ items →
react-windowor@tanstack/react-virtual - Initial bundle over 500KB →
React.lazy+Suspense - State change re-renders distant parts of the tree → state colocation
Comparison table
| Technique | What it caches | When it re-runs | Bundle impact | Available since |
|---|---|---|---|---|
React.memo | Full component render | Prop change (shallow) | None | React 16.6 |
useMemo | Any computed value | Deps array change | None | React 16.8 |
useCallback | Function reference | Deps array change | None | React 16.8 |
React.lazy | Code chunk | First import() | Reduces initial | React 16.6 |
react-window | Visible list items | Scroll / viewport | ~10KB | Library |
useMemo and useCallback are the same mechanism under the hood. useCallback(fn, deps) is shorthand for useMemo(() => fn, deps).
How React handles memoization internally
React's fiber architecture batches state updates using a scheduler based on requestIdleCallback. During the commit phase, memoized components run a check similar to shouldComponentUpdate. It does a shallow object diff via an internal areEqual function. If every prop passes that check, React skips JSX traversal and DOM commits entirely.
V8 also benefits here. Stable function references from useCallback let the engine reuse already-compiled closures instead of allocating new ones each render.
Common mistakes
Mistake 1: Missing deps in useMemo or useCallback
// Stale data: items changes but doubled never updates
const doubled = useMemo(() => items.map(i => i * 2), []);
// Fix: add the dependency
const doubled = useMemo(() => items.map(i => i * 2), [items]);The ESLint rule react-hooks/exhaustive-deps catches this automatically. It is the most common complaint on r/reactjs about hooks.
Mistake 2: Passing new objects or functions to a memoized child
// Re-renders on every parent render because config is a new object each time
const Child = React.memo(({ config }) => <div>{config.theme}</div>);
<Child config={{ theme: "dark", size: 14 }} /> // ❌
// Fix: memoize the object, or pass primitives directly
const config = useMemo(() => ({ theme: "dark", size: 14 }), []);
<Child config={config} /> // ✅Shallow comparison checks references, not contents. A new object literal fails the check even if nothing inside changed. This accounts for a large share of StackOverflow questions about React.memo "not working."
Mistake 3: Using useCallback without memoizing the child
// handleClick is stable, but Child still re-renders every time
const handleClick = useCallback(() => console.log("click"), []);
<Child onClick={handleClick} /> // Child has no React.memouseCallback alone does nothing for re-render prevention. The child needs React.memo too, otherwise it ignores the stable reference entirely.
Mistake 4: Memoizing everything
Adding React.memo to every component adds comparison overhead on every render. For components that receive simple primitives or render rarely, that overhead can exceed the savings. Dan Abramov's blog shows profiler data with +15% CPU overhead from over-memoization. Apply memo only where the DevTools Profiler actually shows a problem.
Real-world usage
- Next.js:
React.lazy/Suspensefor route-level code splitting;/dashboardloads only when visited - Redux Toolkit:
createSelectorfrom Reselect wrapsuseMemologic to cache derived state across the store - Material-UI:
React.memoonListItemto skip re-renders in drawers with 100+ items - TanStack Query:
useQuerymemoizes query data automatically so components only re-render on actual data changes - Vercel commerce template:
useMemofor cart totals,useCallbackfor checkout handlers
Follow-up questions
Q: When does React.memo fail to prevent a re-render?
A: When the component consumes a context that changed, or when a parent calls forceUpdate. Also when props contain objects or functions created inline, because shallow comparison sees them as new values every time.
Q: What is the difference between useMemo and useCallback?
A: useMemo caches any value: an object, array, or computed result. useCallback is a shortcut for useMemo(() => fn, deps). Use useCallback when the cached value is a function, useMemo when it is data.
Q: How do you optimize a list of 10,000 items?
A: Virtualization with react-window or @tanstack/react-virtual. Only visible rows get rendered, roughly 9-15 items instead of 10,000. Memory drops by roughly 100x and scroll stays at 60fps where an unvirtualized list would fall to 15fps or less.
Q: What is the difference between useTransition and useMemo?
A: Different problems. useMemo skips recalculation. useTransition marks a state update as non-urgent so React can interrupt it for more urgent user input. Use both together in search-as-you-type: useMemo for filtering logic, useTransition to keep the input field responsive.
Q: (Senior) In Concurrent Mode, how does the scheduler interact with memoized components?
A: Low-priority updates started with useTransition can be interrupted mid-tree. React may skip rendering memoized components if a higher-priority update arrives. The memo check still runs, but the component might re-render anyway when the scheduler resumes the interrupted work. Measure impact via getCurrentPriorityLevel() from the React scheduler package.
Examples
Dashboard with useMemo and useCallback
A sales dashboard that recalculates totals only when data changes and passes a stable export handler to a child toolbar.
function SalesDashboard({ salesData }: { salesData: Sale[] }) {
// Recalculates only when salesData reference changes
const stats = useMemo(() => {
const total = salesData.reduce((sum, s) => sum + s.amount, 0);
return {
total,
avg: total / salesData.length,
count: salesData.length,
};
}, [salesData]);
// Stable reference: StatsPanel skips re-render when parent re-renders
const handleExport = useCallback(() => {
exportToCSV(stats);
}, [stats]);
return (
<div>
<StatsPanel stats={stats} onExport={handleExport} />
<SalesTable data={salesData} />
</div>
);
}Without useMemo, stats recomputes on every parent render even when salesData has not changed. In a real dashboard with animations running and polling every 30 seconds, that adds up to hundreds of wasted recalculations per session.
Virtualized list with react-window
Rendering 10,000 product items without freezing the browser.
import { FixedSizeList as List } from "react-window";
const VirtualizedList = React.memo(({ items }: { items: Item[] }) => (
<List
height={600} // Fixed container height in px
itemCount={items.length}
itemSize={70} // Each row height in px
width="100%"
>
{({ index, style }) => (
// style contains absolute positioning injected by react-window
<div style={style}>{items[index].name}</div>
)}
</List>
));With 10,000 items at 70px each, the total scroll height is 700,000px. React-window renders only the ~9 visible rows at any point. Chrome Profiler shows scroll at 60fps where an unvirtualized list would drop to 15fps or below.
State colocation to prevent tree-wide re-renders
Moving hover state down to the component that actually needs it.
// ❌ Hover state in App: all children re-render on every mouse move
function App() {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return <ProductList items={products} hoveredId={hoveredId} onHover={setHoveredId} />;
}
// ✅ Hover state in the item itself: only that item re-renders
function ProductCard({ product }: { product: Product }) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={isHovered ? "highlighted" : ""}
>
{product.name}
</div>
);
}This is often the biggest win in practice. Before reaching for memo, check whether the state that triggers re-renders actually needs to live that high in the tree. I have seen 40-component trees drop to single-digit re-renders just by moving one useState call down two levels.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.