React.memo and Preventing Re-renders
React.memo and Preventing Re-renders
How React.memo Works
React.memo is a higher-order component that wraps a functional component and skips re-rendering when the new props are shallowly equal to the previous props. Without memoization, React re-renders a child component every time its parent re-renders, even if the child's props have not changed.
import React, { useState } from 'react';
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
function Dashboard() {
const [count, setCount] = useState(0);
const [items] = useState([
{ id: 1, name: 'Item A' },
{ id: 2, name: 'Item B' },
{ id: 3, name: 'Item C' },
]);
// This re-render no longer causes ExpensiveList to re-render
// because items and onSelect haven't changed
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<ExpensiveList items={items} onSelect={(id) => console.log(id)} />
</div>
);
}
Custom Comparison Functions
By default React.memo uses shallow comparison. You can pass a custom comparator as the second argument for finer control — for example, to compare specific props or ignore certain ones.
const UserAvatar = React.memo(
function UserAvatar({ user, size, onLoad }) {
return (
<img
src={user.avatarUrl}
width={size}
height={size}
alt={user.name}
onLoad={onLoad}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.user.id === nextProps.user.id &&
prevProps.size === nextProps.size
// intentionally ignoring onLoad — it changes every render
);
}
);
Common Pitfalls
Using React.memo on every component adds overhead and makes code harder to maintain. Profile first to confirm a component is actually re-rendering excessively. Memoizing leaf components that receive primitive props (strings, numbers, booleans) provides the most benefit. Memoizing components that receive objects or functions without also memoizing those values in the parent is pointless — the child will still re-render because the reference changes.
// BAD: onSelect is a new reference every render, so memo has no effect
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* Child re-renders every time because this arrow function is recreated */}
<ExpensiveChild onClick={() => console.log('clicked')} />
</div>
);
}
// GOOD: useCallback stabilizes the reference
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveChild onClick={handleClick} />
</div>
);
}
useMemo and useCallback for Performance
useMemo and useCallback for Performance
useMemo for Expensive Computations
useMemo caches the result of a computation between re-renders. It only recalculates when one of its dependencies changes. This is critical when processing large datasets, performing complex calculations, or transforming data for rendering.
import { useMemo } from 'react';
function ProductList({ products, filter, sortBy }) {
const filteredAndSorted = useMemo(() => {
console.log('Computing filtered list...');
return products
.filter(p => p.category === filter)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, filter, sortBy]);
return (
<ul>
{filteredAndSorted.map(p => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
);
}
Creating Stable References for Children
When passing objects, arrays, or functions to memoized children, useMemo and useCallback ensure those references stay stable across re-renders. Without them, React.memo is ineffective because new references defeat shallow comparison.
function Parent() {
const [count, setCount] = useState(0);
const [items] = useState([{ id: 1, name: 'Widget' }]);
const sortedItems = useMemo(() =>
[...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
const handleSelect = useCallback((id) => {
console.log('Selected:', id);
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedList items={sortedItems} onSelect={handleSelect} />
</div>
);
}
When NOT to Use useMemo and useCallback
Do not memoize every value. The overhead of memoization (memory allocation, dependency tracking, comparison) can exceed the cost of recalculating a simple expression. Skip memoization when:
- The computation is cheap (string concatenation, simple arithmetic)
- The component rarely re-renders
- The result is not passed to a memoized child or used as a hook dependency
- The dependencies change on every render anyway
// UNNECESSARY: this is a trivial computation
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// UNNECESSARY: if parent re-renders frequently and deps are unstable
const handleClick = useCallback(() => {
doSomething(prop1, prop2); // prop1 and prop2 change every render
}, [prop1, prop2]);
Code Splitting with React.lazy and Suspense
Code Splitting with React.lazy and Suspense
Why Code Split Large Apps
A monolithic bundle forces the browser to download and parse the entire application before rendering anything. As the codebase grows, this increases Time to Interactive (TTI) and First Contentful Paint (FCP). Code splitting breaks the bundle into smaller chunks that load on demand, reducing the initial payload.
React.lazy for Route-Based Splitting
React.lazy dynamically imports a component and renders it asynchronously. Pair it with Suspense to show a fallback while the chunk loads.
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div className="skeleton" style={{ height: '100vh' }} />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Component-Level Code Splitting
Splitting by route is the most common pattern, but you can also lazy-load heavy components within a page. This is useful for modals, editors, charts, or any component that is not visible on initial render.
import { lazy, useState, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
function AnalyticsPage() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Analytics</h1>
<button onClick={() => setShowChart(true)}>Load Chart</button>
{showChart && (
<Suspense fallback={<p>Loading chart...</p>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Error Boundaries Around Lazy Components
If a lazy-loaded chunk fails to load (network error, 404), React throws. Wrap lazy components in an error boundary to handle failures gracefully.
import React, { Suspense, lazy } from 'react';
const AdminPanel = lazy(() => import('./AdminPanel'));
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div role="alert">
<h2>Something went wrong loading this section.</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<p>Loading...</p>}>
<AdminPanel />
</Suspense>
</ErrorBoundary>
);
}
Preloading Strategies
You can preload a lazy component before the user navigates to it, eliminating the loading delay entirely.
// Preload on hover — instant navigation when clicked
const Settings = lazy(() => import('./pages/Settings'));
function NavLink({ to, children }) {
const preload = () => {
// Trigger the dynamic import early
import('./pages/Settings');
};
return (
<Link to={to} onMouseEnter={preload}>
{children}
</Link>
);
}
Virtualization for Large Lists
Virtualization for Large Lists
The Problem with Large Lists
Rendering thousands of DOM nodes is expensive. Each node consumes memory, takes time to paint, and adds event listener overhead. Virtualization solves this by rendering only the items visible in the viewport plus a small buffer, recycling DOM elements as the user scrolls.
react-window for Fixed-Height Lists
react-window is the modern replacement for react-virtualized. It provides FixedSizeList for lists where every item has the same height.
import { FixedSizeList } from 'react-window';
const Row = ({ index, style, data }) => (
<div style={style} className="list-item">
<span>{data[index].name}</span>
<span>{data[index].email}</span>
</div>
);
function UserList({ users }) {
return (
<FixedSizeList
height={600}
width="100%"
itemCount={users.length}
itemSize={50}
itemData={users}
>
{Row}
</FixedSizeList>
);
}
Variable-Height Items with VariableSizeList
When items have different heights, use VariableSizeList. You must provide a function that returns the height for each item index.
import { VariableSizeList } from 'react-window';
const messages = [
{ id: 1, text: 'Short message' },
{ id: 2, text: 'A much longer message that spans multiple lines and will require more vertical space to render properly' },
{ id: 3, text: 'Another short one' },
];
const getMessageHeight = (index) => {
const msg = messages[index];
return msg.text.length > 50 ? 80 : 40;
};
function MessageList() {
return (
<VariableSizeList
height={500}
width="100%"
itemCount={messages.length}
itemSize={getMessageHeight}
>
{({ index, style }) => (
<div style={style} className="message">
{messages[index].text}
</div>
)}
</VariableSizeList>
);
}
Performance Tips for Virtualized Lists
Use itemData to pass data to row components instead of creating closures — closures cause new function references on every render, defeating memoization. Ensure row components are memoized with React.memo when possible. Avoid reading layout properties like offsetHeight inside row components since it triggers forced reflow. Set overscanCount (default 5) to balance scroll smoothness against DOM node count.
// GOOD: data passed via itemData, no closures
const Row = React.memo(({ index, style, data }) => (
<div style={style}>{data[index].name}</div>
));
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={48}
itemData={items}
overscanCount={10}
>
{Row}
</FixedSizeList>
When NOT to Virtualize
Virtualization adds complexity (scroll position management, dynamic heights, accessibility). For lists with fewer than 50-100 items, standard rendering is sufficient. Virtualization also breaks native scrolling behavior and can cause issues with screen readers, search engines, and Ctrl+F browser search.
Profiling and Identifying Bottlenecks
Profiling and Identifying Bottlenecks
React DevTools Profiler
The React DevTools Profiler records every render and shows which components re-rendered, how long each render took, and why it re-rendered (props change, hooks change, parent render). Always profile before optimizing — premature optimization wastes time and adds unnecessary complexity.
To use it: open React DevTools, go to the Profiler tab, click record, interact with your app, then stop recording. The flamegraph view shows each component as a colored bar — wider bars took longer to render. The commit view shows a timeline of all commits (batches of updates).
Why Did This Render?
Add why-did-you-render to detect unnecessary re-renders during development. It logs to the console when a component re-renders with the same props.
import React from 'react';
import ReactDOM from 'react-dom';
import './wdyr';
import App from './App';
// wdyr.js
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, { trackAllPureComponents: true });
}
// Tag components you want to track
const ExpensiveComponent = ({ data }) => {
// ... renders list
};
ExpensiveComponent.whyDidYouRender = true;
Chrome DevTools Performance Tab
The Chrome Performance tab captures CPU, memory, and rendering activity. Key panels:
- Main thread: shows JavaScript execution, layout, paint, and composite events
- Summary pie chart: breaking down time by scripting, rendering, and painting
- Long tasks: tasks exceeding 50ms that block the main thread
Record a session, look for long tasks, and identify which React components or effects are causing them. Use User Timing API marks to label specific operations.
import { useEffect } from 'react';
function DataGrid({ rows }) {
useEffect(() => {
performance.mark('datagrid-render-start');
}, []);
useEffect(() => {
performance.mark('datagrid-render-end');
performance.measure(
'DataGrid total render',
'datagrid-render-start',
'datagrid-render-end'
);
});
return (
<table>
{rows.map(row => (
<tr key={row.id}>
{row.cells.map(cell => <td key={cell.id}>{cell.value}</td>)}
</tr>
))}
</table>
);
}
Key Metrics to Watch
Track these performance metrics to guide optimization decisions:
- Time to First Byte (TTFB): server response time, affects perceived speed
- First Contentful Paint (FCP): when the first content appears, target under 1.8s
- Largest Contentful Paint (LCP): when the largest element renders, target under 2.5s
- Cumulative Layout Shift (CLS): visual stability, target under 0.1
- Total Blocking Time (TBT): sum of tasks over 50ms, target under 200ms
import { useEffect } from 'react';
function usePerformanceMetrics() {
useEffect(() => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.startTime.toFixed(0)}ms`);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'first-contentful-paint', buffered: true });
return () => observer.disconnect();
}, []);
}
Common React Performance Anti-Patterns
- Creating objects/functions in render — causes child re-renders when passed as props
- Running expensive computations without useMemo — blocks the main thread
- Loading everything eagerly — large bundles delay interactivity
- Optimizing without profiling — wastes effort on non-bottlenecks
- Ignoring React DevTools warnings — they indicate real performance issues
Quiz
1. You have a parent component that re-renders frequently due to a timer. A child component receives the same data array and a callback function on every render. The child is wrapped in React.memo but still re-renders every time. What is the most likely cause?
2. What is the primary benefit of using React.lazy with Suspense for route-based code splitting?
3. When should you NOT use virtualization for a list in a React application?
Flashcards
Question
When does React.memo actually prevent re-renders, and when does it fail?
Click to reveal answer
Answer
React.memo prevents re-renders only when the new props are shallowly equal to the previous props. It fails when props are objects, arrays, or functions created inline in the parent — because each render creates a new reference, and shallow comparison sees them as different. To make React.memo effective, stabilize object references with useMemo, array references with useMemo, and function references with useCallback.
Question
What is the difference between React.lazy and dynamic import()?
Click to reveal answer
Answer
dynamic import() is a standard JavaScript feature that returns a Promise resolving to a module. React.lazy wraps dynamic import() and integrates it with React's rendering lifecycle — it triggers the import when the component first renders and shows a Suspense fallback while loading. You could use dynamic import() manually with state management, but React.lazy handles the loading, error, and rendering states automatically within React's component tree.
Question
How does list virtualization improve performance, and what does it sacrifice?
Click to reveal answer
Answer
Virtualization renders only the items visible in the viewport plus a small buffer, keeping the DOM node count constant regardless of list length. This dramatically reduces memory usage, paint time, and layout cost for large lists. The tradeoffs are: added code complexity, broken Ctrl+F browser search, potential accessibility issues with screen readers, and inability to use native browser features like Find in Page that rely on all content being in the DOM.
Revision Notes
Key Takeaways
- 1. React.memo wraps a component to skip re-renders when props are shallowly equal — always stabilize object, array, and function references in the parent with useMemo and useCallback for it to be effective
- 2. useMemo caches expensive computations and recalculates only when dependencies change — skip it for trivial calculations where the overhead of memoization exceeds the recalculation cost
- 3. useCallback caches function references to prevent child re-renders — essential when passing callbacks to memoized children, but unnecessary if the child is not memoized
- 4. React.lazy + Suspense splits code at the route or component level — load heavy components on demand to reduce initial bundle size and improve Time to Interactive
- 5. Virtualization renders only visible items in a list — essential for lists with hundreds or thousands of items, but adds complexity and breaks native browser search
- 6. Always profile before optimizing — use React DevTools Profiler, Chrome DevTools Performance tab, and why-did-you-render to identify actual bottlenecks instead of guessing
- 7. Wrap lazy-loaded components in error boundaries to handle chunk loading failures gracefully
Interview Tips
- • Explain the render cycle: when a parent re-renders, all children re-render unless wrapped in React.memo AND their props are shallowly equal — show you understand the reference equality trap with objects and functions
- • Walk through a real optimization scenario: profile the app, identify a component re-rendering unnecessarily, apply React.memo, stabilize props with useMemo/useCallback, and measure the improvement
- • Discuss code splitting strategy: route-based splitting for large pages, component-based splitting for heavy modals/editors, and preloading on hover for instant navigation
- • Explain when virtualization is worth the complexity: large data sets (1000+ items), but warn about accessibility trade-offs and the overhead for small lists
- • Describe the tradeoff of memoization: it trades memory and computation (dependency tracking) for rendering speed — profile to confirm the optimization is actually needed
- • Show you know the tools: React DevTools Profiler flamegraph, Chrome DevTools Performance tab for long tasks, User Timing API for custom measurements
Cheat Sheet
React.memo
const Memoized = React.memo(Component); — skips re-render when props are shallowly equal
useMemo
const val = useMemo(() => expensiveCalc(dep), [dep]); — caches computed value, recalculates only when deps change
useCallback
const fn = useCallback(() => doSomething(dep), [dep]); — caches function reference for stable identity
React.lazy
const Comp = lazy(() => import('./Comp')); — dynamic import integrated with React rendering lifecycle
Suspense
<Suspense fallback={
ErrorBoundary
Wrap lazy components in error boundaries to catch chunk loading failures gracefully
Virtualization
react-window FixedSizeList renders only visible items — use for lists with 100+ items
Profile first
React DevTools Profiler + Chrome Performance tab — measure before and after every optimization