🧠 Memoization with React.memo
React re-renders eagerly by default, and most of the time that's a good thing. But in large trees, expensive lists, or heavy widgets, needless re-renders add up. React.memo lets a component skip rendering when its props haven't changed — if you understand exactly what "changed" means to React.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how React's default rendering cascade works and when it becomes a performance problem
- Define memoization and wrap a component in
React.memoto skip unnecessary re-renders - Describe React's shallow prop comparison and why new object/function references defeat memoization
- Write a custom comparison function and judge when it is worth the cost
- Decide when memoization helps and when it is premature optimization
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Diagnose and fix a memoized list that re-renders every keystroke.
In This Lesson
Why Re-renders Add Up
React follows one simple rule: when a component re-renders — because its state changed or it received new props — all of its children re-render too, by default, whether or not their own data changed. This keeps the UI predictable and correct. React then diffs the result against the previous virtual DOM and only touches the real DOM where something actually differs, so a re-render is not the same as a DOM update.
That distinction matters: rendering (calling your component function) is usually cheap, and React's DOM reconciliation is fast. Problems appear only when the render work itself is expensive or repeated at scale:
- Deep component trees where one top-level state change cascades to hundreds of nodes
- Long lists where changing one item re-runs every item's render function
- Components that do real work while rendering — formatting large datasets, building charts
- High-frequency updates: a counter, a controlled input, an animation loop
In the tree above, a single state change in App asks every descendant to render again — even a product card whose data has not moved. That is exactly the wasted work React.memo is designed to skip.
📖 Real-World Analogy: Repainting One Room
Imagine repainting one bedroom. The default React approach is like re-inspecting every room in the house to confirm each one still looks right. The inspection is fast, but if a room is enormous and detailed, re-checking it needlessly wastes time. React.memo is a note on the door: "nothing changed in here, skip it."
What Memoization Means
Memoization is a general optimization technique — not React-specific — that caches the result of a function for a given set of inputs, and returns the cached result when the same inputs appear again instead of recomputing. The name comes from "memo," short for memoize, meaning "to remember a result," not "memo" as in a note.
Here is the classic example in plain JavaScript. Computing Fibonacci naively recomputes the same subproblems over and over; a cache remembers each answer:
// Slow: recomputes the same subproblems repeatedly
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Memoized: each result is computed once, then reused
function makeFib() {
const cache = new Map();
function fib(n) {
if (cache.has(n)) return cache.get(n); // cache hit
const value = n <= 1 ? n : fib(n - 1) + fib(n - 2);
cache.set(n, value); // remember it
return value;
}
return fib;
}
const fib = makeFib();
console.log(fib(40)); // computed once
console.log(fib(40)); // instant — served from the cache
React applies the same idea to components. When a component is memoized, React remembers the output it produced for a given set of props. If the next render passes the same props, React reuses the remembered output and skips calling the component function at all.
Introducing React.memo
React.memo is a higher-order component: you pass it a component, and it returns a new memoized component. On each render React compares the new props against the previous props; if they are equal, the memoized component is skipped.
last render?} B -->|Yes| C[Skip — reuse
previous output] B -->|No| D[Re-render &
remember new output]
In modern React you usually import memo directly rather than reaching through the React namespace:
import { memo } from 'react';
function Greeting({ name }) {
console.log('Greeting rendered');
return <h2>Hello, {name}!</h2>;
}
// Memoized version — skips rendering when `name` is unchanged
const MemoGreeting = memo(Greeting);
export default MemoGreeting;
The memoized component is used exactly like the original. The payoff shows up when a parent re-renders for reasons unrelated to the child. Here a counter re-renders Dashboard constantly, but the expensive chart is spared because its props never change:
import { memo, useState } from 'react';
const ExpensiveChart = memo(function ExpensiveChart({ data, width, height }) {
console.log('Rendering expensive chart');
// ...imagine heavy layout/measurement work here...
return <canvas width={width} height={height} />;
});
function Dashboard({ chartData }) {
const [count, setCount] = useState(0);
return (
<div>
<p>Counter: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
{/* Skips re-rendering when only `count` changes */}
<ExpensiveChart data={chartData} width={800} height={400} />
</div>
);
}
⚠️ One important caveat
React.memo only guards against re-renders caused by a parent. It does not stop a component from re-rendering because of its own useState/useReducer changes, or because a useContext value it reads changed. Memo compares props — nothing else.
When to Reach for It
React.memo is a targeted tool, not a default wrapper for every component. It pays off in a few recognizable situations.
Pure components in long lists
List items are the sweet spot: when one row changes, memoization keeps the other rows from re-rendering. This is where memoization delivers the most measurable win.
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
);
});
function ProductList({ products, onAddToCart }) {
return (
<div className="product-list">
{products.map(product => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</div>
);
}
Expensive components with stable props
A chart, a map, or a rich editor that sits inside a frequently-updating parent but whose own inputs rarely change is a strong candidate — as we saw with ExpensiveChart above.
✅ Good memoization candidates
- Pure function components (same props → same output, no side effects during render)
- Row/item components rendered many times in a list or grid
- Components whose render is genuinely expensive but whose props are stable
When NOT to use it
Memoization is not free — React still has to run the prop comparison on every render. Skip it when:
- The component receives different props nearly every render (e.g. a live clock). The comparison always fails, so you pay the cost with no benefit.
- The component is trivially cheap — a one-line
<p>. Re-rendering is cheaper than comparing. - The component reads context that changes often — it re-renders on context change regardless of memo.
📖 Premature optimization
Donald Knuth's warning applies directly: "premature optimization is the root of all evil." Build the feature with plain components first. Only add memo after the Profiler shows a real, repeated render cost. Wrapping everything "just in case" adds complexity and comparison overhead without a proven payoff.
The Shallow-Comparison Trap
By default React.memo does a shallow comparison of props: for each prop it checks Object.is(prev, next). For primitives (strings, numbers, booleans) that works intuitively. For objects, arrays, and functions it compares references, not contents — and this is where most memoization bugs live.
The problem: creating an object, array, or arrow function inside a parent's render produces a brand-new reference every time. To the shallow check, a new reference always looks like a changed prop, so the memoized child re-renders anyway.
Here is the trap in code, and the fix. The parent creates a fresh data object each render, so MemoChild never gets to skip:
import { memo, useMemo, useState } from 'react';
const MemoChild = memo(function Child({ data }) {
console.log('Child rendered');
return <p>Value: {data.value}</p>;
});
function BrokenParent() {
const [count, setCount] = useState(0);
const data = { value: 42 }; // ❌ new reference every render
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild data={data} /> {/* re-renders on every click */}
</>
);
}
function FixedParent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ value: 42 }), []); // ✅ stable reference
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild data={data} /> {/* now correctly skipped */}
</>
);
}
Function props have the same problem: an inline onClick={() => ...} is a new function every render. The fix is useCallback — which is the subject of the next lesson. For now, the key insight is: React.memo only helps if every object, array, and function prop keeps a stable reference between renders.
⚠️ Broken memoization chains
Memoization only works if the whole path is stable. If a non-memoized middle component sits between your parent and a memoized child, that middle component re-renders and re-invokes the child's render, bypassing the memo. Memoize the intermediate components too, or restructure so the stable subtree is passed as children.
Custom Comparison Functions
React.memo accepts an optional second argument: a comparison function areEqual(prevProps, nextProps). Return true to skip the render (props considered equal), false to re-render. This lets you compare only the props that actually affect the output.
const UserBadge = memo(
function UserBadge({ user, lastSeen, theme }) {
return <span>{user.name}</span>;
},
(prev, next) => {
// Only re-render when the visible identity changes.
// `lastSeen` and `theme` don't affect this output, so ignore them.
return prev.user.id === next.user.id && prev.user.name === next.user.name;
}
);
⚠️ Keep the comparator cheap — and correct
The comparison runs on every render, so an expensive comparator can cost more than the render it prevents. Never do this:
// ❌ JSON.stringify on every render is slow and fragile
(prev, next) => JSON.stringify(prev) === JSON.stringify(next)
Also beware of correctness: if you forget to compare a prop that does affect the output, you'll produce a stale UI that won't update. Custom comparators trade safety for speed — reach for them only when a targeted shallow fix (stabilizing references with useMemo/useCallback) isn't enough.
A realistic use is a data-visualization component with many props where only a few are visually relevant:
const DataChart = memo(
function DataChart({ data, dimensions, options, onPointClick }) {
return (
<div style={{ width: dimensions.width, height: dimensions.height }}>
{/* chart rendering */}
</div>
);
},
(prev, next) => {
if (prev.dimensions.width !== next.dimensions.width) return false;
if (prev.dimensions.height !== next.dimensions.height) return false;
if (prev.data.length !== next.data.length) return false;
if (prev.options.color !== next.options.color) return false;
// onPointClick reference is ignored on purpose (stabilize it with useCallback in the parent)
return true;
}
);
💡 Class components: the older equivalents
Before hooks, class components used React.PureComponent (an automatic shallow prop/state comparison) or a hand-written shouldComponentUpdate(nextProps, nextState). React.memo is the function-component counterpart. New code should be function components with memo; you'll only meet PureComponent in legacy codebases.
Measuring Before Optimizing
Never guess about performance — measure. The React DevTools Profiler records every render, shows why each component rendered, and highlights how long each took. Add memoization only where the Profiler shows a component rendering often and costing real time.
A quick, no-tooling way to see re-renders while developing is a console.log at the top of a component. For finding which prop triggered a render, a small custom hook is invaluable:
import { useRef, useEffect } from 'react';
// Logs exactly which props changed between renders.
function useWhyDidYouUpdate(name, props) {
const previous = useRef();
useEffect(() => {
if (previous.current) {
const changed = {};
for (const key of Object.keys({ ...previous.current, ...props })) {
if (previous.current[key] !== props[key]) {
changed[key] = { from: previous.current[key], to: props[key] };
}
}
if (Object.keys(changed).length) {
console.log('[why-did-you-update]', name, changed);
}
}
previous.current = props;
});
}
function ProfileCard(props) {
useWhyDidYouUpdate('ProfileCard', props);
return <div>{/* ... */}</div>;
}
The workflow is always the same loop: profile → find the hotspot → confirm the cause → apply the smallest fix → profile again to verify it worked.
Hands-on Exercise
🏋️ Fix the List That Won't Stop Rendering
Objective: A product list is wrapped in React.memo, yet every card re-renders on each keystroke in the search box. Find the two reasons and fix them.
import { memo, useState } from 'react';
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
console.log('Rendering', product.name); // fires for EVERY card on each keystroke
return (
<div className="card">
<h3>{product.name}</h3>
<button onClick={onAddToCart}>Add to Cart</button>
</div>
);
});
function ProductPage({ products }) {
const [search, setSearch] = useState('');
const visible = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
{visible.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={() => console.log('add', product.name)}
/>
))}
</div>
);
}
Your tasks:
- Explain why the memoized cards still re-render on every keystroke.
- Fix the offending prop so unchanged cards are skipped.
- State which hook stabilizes the fix (you'll implement it fully next lesson).
💡 Hint
Look at the onAddToCart prop. What kind of value is () => console.log(...), and how many times is it created? Recall that React.memo compares prop references with Object.is.
✅ Solution
Why it breaks: the inline arrow onAddToCart={() => ...} is a brand-new function on every render of ProductPage. Since a keystroke updates search and re-renders the page, every card receives a new function reference, the shallow comparison fails, and every card re-renders.
The fix is to give each card a stable handler. Pass the id into a single stable callback rather than creating a per-card closure:
import { memo, useCallback, useState } from 'react';
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
console.log('Rendering', product.name);
return (
<div className="card">
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
);
});
function ProductPage({ products }) {
const [search, setSearch] = useState('');
// Stable reference — created once, never changes.
const handleAddToCart = useCallback((id) => {
console.log('add', id);
}, []);
const visible = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
{visible.map(product => (
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
))}
</div>
);
}
Now typing filters the list without re-rendering the cards that stay on screen. The stabilizing hook is useCallback.
🎯 Quick Quiz
Question 1: What does React.memo compare to decide whether to skip a re-render?
Question 2: Why does passing data={{ value: 42 }} inline defeat a memoized child?
Question 3: Which component is the worst candidate for React.memo?
Summary & Quiz
🎉 Key Takeaways
- By default, a re-rendering component re-renders all its children; that's cheap until render work is expensive or repeated at scale.
React.memomemoizes a component so it skips re-rendering when its props are unchanged.- Memo uses a shallow comparison: new object/array/function references count as "changed," which is the number-one reason memoization silently fails.
- Stabilize reference props with
useMemo(values) anduseCallback(functions) for memo to work. - A custom comparator can compare only relevant props, but must stay cheap and correct.
- Measure first with the Profiler — avoid premature, blanket memoization.
📚 Further Reading
- React docs —
memo - React docs — Render and Commit
- React docs —
PureComponent(legacy classes) - Kent C. Dodds — When to useMemo and useCallback
🚀 What's Next?
You've seen that React.memo is only as good as the stability of the props you feed it. Next we'll dig into the two hooks that provide that stability — useMemo for cached values and useCallback for cached functions.
🎉 Well done!
You can now spot wasted renders and reach for memoization deliberately, not reflexively.