Skip to main content

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.memo caches a component's output; useMemo caches a computed value; useCallback caches a function reference
  • Profile first with React DevTools Profiler. State colocation and code splitting usually give bigger wins than adding memo everywhere
  • List with 200+ items → virtualization. Bundle over 500KB → React.lazy. Handler prop causing child re-renders → useCallback + React.memo

Quick example

tsx
// 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 stableReact.memo
  • Expensive derived data: reduce, map, sortuseMemo
  • Handler passed as prop to a memoized childuseCallback
  • List with 200+ itemsreact-window or @tanstack/react-virtual
  • Initial bundle over 500KBReact.lazy + Suspense
  • State change re-renders distant parts of the tree → state colocation

Comparison table

TechniqueWhat it cachesWhen it re-runsBundle impactAvailable since
React.memoFull component renderProp change (shallow)NoneReact 16.6
useMemoAny computed valueDeps array changeNoneReact 16.8
useCallbackFunction referenceDeps array changeNoneReact 16.8
React.lazyCode chunkFirst import()Reduces initialReact 16.6
react-windowVisible list itemsScroll / viewport~10KBLibrary

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

tsx
// 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

tsx
// 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

tsx
// handleClick is stable, but Child still re-renders every time const handleClick = useCallback(() => console.log("click"), []); <Child onClick={handleClick} /> // Child has no React.memo

useCallback 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 / Suspense for route-level code splitting; /dashboard loads only when visited
  • Redux Toolkit: createSelector from Reselect wraps useMemo logic to cache derived state across the store
  • Material-UI: React.memo on ListItem to skip re-renders in drawers with 100+ items
  • TanStack Query: useQuery memoizes query data automatically so components only re-render on actual data changes
  • Vercel commerce template: useMemo for cart totals, useCallback for 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.

tsx
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.

tsx
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.

tsx
// ❌ 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?