β‘ Performance Optimization with Memoization
React is fast by default β until a busy component tree starts re-rendering more than it needs to. Memoization is the toolkit for skipping work that would produce the same result: React.memo for components, useMemo for values, useCallback for functions. Used well, they turn a sluggish grid snappy. Used everywhere, they just add overhead.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Describe how and when React re-renders a component and its children
- Explain memoization and the role of referential equality
- Apply
React.memo,useMemo, anduseCallbackcorrectly - Recognize when memoization helps and when it just adds cost
- Measure render performance with the React DevTools Profiler
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Optimize a shopping-cart component with all three tools.
In This Lesson
How React Re-renders
To optimize rendering you first have to understand it. React re-renders in three situations:
- A component's own state changes.
- A component's props change.
- Its parent re-renders β which, by default, re-renders all of its children, whether or not their props changed.
π¨βπ©βπ§ A useful analogy: React's cascade is like a family getting ready in the morning. If a parent decides to change outfits (state change), every child at least has to check whether they need to change too β even the ones who end up wearing the same thing.
For small apps this is completely fine β a re-render is cheap, and React only touches the real DOM where output actually differs. The trouble appears in deep trees or lists where an expensive child re-renders on every unrelated parent update.
What Is Memoization?
Memoization is a general programming technique: cache the result of an expensive computation and return the cached value when the same inputs come back. It's remembering the answer instead of recomputing it.
β Real-world analogy: A barista who keeps a few popular drinks pre-made serves them instantly (cached). Only when someone orders something different β or the pre-made batch goes stale β do they brew fresh. In React, the "same order" is "the same inputs," and "stale" is "a dependency changed."
In React, memoization does three jobs:
- Skip re-rendering a component whose props didn't change (
React.memo). - Skip recomputing an expensive value (
useMemo). - Keep a function or object referentially stable across renders (
useMemo/useCallback).
π Referential equality β the concept behind it all
In JavaScript, {} === {} is false and (() => {}) === (() => {}) is false. Two objects/functions are "equal" only if they're the same reference. React's memoization compares props by reference, so a freshly created object or function counts as "changed" even when its contents are identical. Half of memoization is about keeping references stable.
React.memo for Components
React.memo wraps a component so React skips re-rendering it when its props haven't changed (by a shallow comparison). It's the tool for breaking the default cascade.
import { memo, useState } from 'react';
function ProfileCard({ user }) {
console.log('ProfileCard rendered');
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Memoized: re-renders only when the `user` prop reference changes
const MemoProfileCard = memo(ProfileCard);
function Dashboard() {
const [user] = useState({ name: 'Jane', email: 'jane@example.com' });
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Clicked {count}</button>
<ProfileCard user={user} /> {/* re-renders on every click */}
<MemoProfileCard user={user} /> {/* skips β user is unchanged */}
</div>
);
}
Clicking the button changes count, re-rendering Dashboard. The plain ProfileCard re-renders needlessly; MemoProfileCard is skipped because its user reference is stable.
Custom comparison
By default memo shallow-compares props. For complex props you can supply your own comparator. It returns true when props are equal (skip render) β the opposite sense of the old shouldComponentUpdate:
const Row = memo(RowComponent, (prev, next) => {
// return true => treat as equal => skip re-render
return prev.id === next.id && prev.label === next.label;
});
The Referential-Equality Trap
Here's the gotcha that trips everyone up: React.memo alone often does nothing, because objects and functions are recreated on every render.
function Parent() {
const [count, setCount] = useState(0);
const user = { name: 'Jane', age: 30 }; // π΄ new object each render
const handleClick = () => console.log('hi'); // π΄ new function each render
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Re-renders every time despite memo β props are new references */}
<MemoChild user={user} onClick={handleClick} />
</div>
);
}
Every click recreates user and handleClick. Because MemoChild compares by reference, it sees "new props" and re-renders anyway. React.memo is only as good as the stability of the props you feed it β which is exactly why useMemo and useCallback exist.
useMemo for Values
useMemo caches the result of a computation, recomputing only when its dependencies change. Use it for genuinely expensive calculations and to keep object/array props referentially stable.
import { useMemo } from 'react';
function Report({ rows, filter }) {
// Recomputes only when rows or filter change β not on every render
const summary = useMemo(() => {
console.log('crunching numbersβ¦');
return computeExpensiveSummary(rows, filter);
}, [rows, filter]);
return <SummaryView data={summary} />;
}
And to fix the referential trap from the previous section β memoize the object so its reference stays stable:
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Jane');
// Same reference until `name` changes
const user = useMemo(() => ({ name, age: 30 }), [name]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoChild user={user} /> {/* now genuinely skips on count changes */}
</div>
);
}
π‘ Use useMemo whenβ¦
- a calculation is measurably expensive (sorting/filtering large lists, parsing);
- you pass an object or array as a prop to a memoized child;
- a value is a dependency of another hook and must stay stable.
useCallback for Functions
useCallback is useMemo for functions: it returns the same function reference between renders until its dependencies change. Its main job is keeping callbacks stable when you pass them to memoized children.
import { useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Jane');
// Stable reference until `name` changes
const handleGreet = useCallback(() => {
console.log(`Hi from ${name}`);
}, [name]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoButton onClick={handleGreet} label="Greet" />
</div>
);
}
β Remember the equivalence
useCallback(fn, deps) is exactly useMemo(() => fn, deps). Reach for useCallback when the memoized value is a function β event handlers passed to React.memo children, or functions used as effect/hook dependencies.
When (and When Not) to Memoize
Memoization isn't free β every memo/useMemo/useCallback adds a comparison and stores a cached value in memory. Applied blindly, it can make code slower and harder to read.
β Do memoize
- Components that render often with the same props.
- Components doing genuinely expensive work.
- Expensive components deep in the tree re-triggered by parent state.
- Objects/functions passed as props to memoized children.
β Don't memoize
- Components that always receive different props anyway.
- Cheap components that render in microseconds.
- Trivial values β
const sum = a + b;never needsuseMemo. - Everything "just in case" β the overhead outweighs the gain.
β οΈ "Premature optimization is the root of all evil." Start without memoization. Measure. Add it only where the profiler shows a real, repeated cost.
π€ The React Compiler changes the calculus
The React Compiler (introduced with React 19) can automatically memoize components and values at build time β potentially making most manual useMemo/useCallback unnecessary in projects that adopt it. Understanding the manual tools still matters: it's how you reason about re-renders, read existing code, and optimize projects that haven't enabled the compiler.
Measuring Performance
Never optimize by guesswork. Measure first, so you fix a real bottleneck instead of a suspected one.
React DevTools Profiler
The Profiler tab in React DevTools is the primary tool:
- Record an interaction and see which components rendered.
- Read why each component rendered (props, state, parent).
- Compare render durations before and after a change.
Other tools
| Tool | Use |
|---|---|
| why-did-you-render | Logs avoidable re-renders and the prop that caused them |
| Lighthouse | Overall page performance and Core Web Vitals |
<Profiler> API | Programmatic render timing in code |
Hands-on: Optimize a Cart
ποΈ Speed up a shopping cart
Objective: Apply all three tools to a cart where typing a coupon code shouldn't re-render every line item.
Starting point (unoptimized):
function ShoppingCart() {
const [items, setItems] = useState([
{ id: 1, name: 'Laptop', price: 999, quantity: 1 },
{ id: 2, name: 'Headphones', price: 99, quantity: 1 },
{ id: 3, name: 'Keyboard', price: 59, quantity: 1 },
]);
const [coupon, setCoupon] = useState('');
const total = items.reduce((s, i) => s + i.price * i.quantity, 0);
const discount = coupon === 'SAVE10' ? total * 0.1 : 0;
const updateQuantity = (id, qty) =>
setItems(items.map(i => (i.id === id ? { ...i, quantity: qty } : i)));
const removeItem = (id) => setItems(items.filter(i => i.id !== id));
// ...renders CartItem for each item + a coupon input
}
Your tasks:
- Wrap
CartIteminReact.memo. - Memoize
total,discount, andfinalTotalwithuseMemo. - Stabilize
updateQuantityandremoveItemwithuseCallback. - Pick correct dependency arrays for each.
π‘ Hint
Use the functional updater form (setItems(prev => β¦)) inside your callbacks so they don't depend on items β that lets their dependency arrays be empty and their references stay stable forever. total depends on items; discount depends on coupon and total.
β Sample solution
import { memo, useCallback, useMemo, useState } from 'react';
const CartItem = memo(function CartItem({ item, updateQuantity, removeItem }) {
return (
<div className="cart-item">
<h3>{item.name}</h3>
<p>${item.price}</p>
<input
type="number" min="1" value={item.quantity}
onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
/>
<p>Subtotal: ${(item.price * item.quantity).toFixed(2)}</p>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
);
});
function ShoppingCart() {
const [items, setItems] = useState([
{ id: 1, name: 'Laptop', price: 999, quantity: 1 },
{ id: 2, name: 'Headphones', price: 99, quantity: 1 },
{ id: 3, name: 'Keyboard', price: 59, quantity: 1 },
]);
const [coupon, setCoupon] = useState('');
const total = useMemo(
() => items.reduce((s, i) => s + i.price * i.quantity, 0),
[items]
);
const discount = useMemo(
() => (coupon === 'SAVE10' ? total * 0.1 : 0),
[coupon, total]
);
const finalTotal = useMemo(() => total - discount, [total, discount]);
// Empty deps: functional updaters mean these never need to change
const updateQuantity = useCallback((id, qty) => {
setItems(prev => prev.map(i => (i.id === id ? { ...i, quantity: qty } : i)));
}, []);
const removeItem = useCallback((id) => {
setItems(prev => prev.filter(i => i.id !== id));
}, []);
return (
<div className="shopping-cart">
<h2>Your Cart</h2>
{items.map(item => (
<CartItem key={item.id} item={item}
updateQuantity={updateQuantity} removeItem={removeItem} />
))}
<input value={coupon} onChange={(e) => setCoupon(e.target.value)}
placeholder="Coupon code" />
<p>Subtotal: ${total.toFixed(2)}</p>
{discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>}
<p>Total: ${finalTotal.toFixed(2)}</p>
</div>
);
}
Now typing in the coupon field re-renders only the cart shell β each memoized CartItem receives stable props and skips. Verify it in the Profiler!
π― Quick Quiz
Question 1: By default, what happens to a component's children when the component re-renders?
Question 2: Why might a React.memo child still re-render every time?
Question 3: Which is the right tool to keep an event handler's reference stable across renders?
Summary & Quiz
π Key Takeaways
- By default, a parent re-render cascades to all its children.
- Memoization skips work that would produce the same result, hinging on referential equality.
React.memoskips a component;useMemocaches a value;useCallbackstabilizes a function.React.memoonly helps if its props are referentially stable β that's whyuseMemo/useCallbackpair with it.- Measure first, memoize where it pays off, and expect the React Compiler to automate much of this going forward.
π Further Reading
- React docs β memo
- React docs β useMemo
- React docs β useCallback
- React docs β React Compiler
- Kent C. Dodds β When to useMemo and useCallback
π What's Next?
Local component state and memoization take you a long way, but large apps need shared, predictable state. Next we'll step into Redux Core Concepts β actions, reducers, and a single global store.
π Nicely optimized!
You can now find and fix wasteful renders. Let's scale state management up.