Performance Optimization
React.memo, useMemo, and useCallback with a concrete before/after re-render example, and when not to over-optimize.
Why a component re-renders
A React component re-renders whenever its own state changes, whenever its parent re-renders (by default, every child re-renders along with it, regardless of whether the props it received actually changed), or whenever a context it consumes changes. Re-rendering itself is cheap in most cases — React's whole design assumes components re-render often — but re-rendering an expensive component, or re-rendering a large subtree unnecessarily, is where real performance problems show up in practice.
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveList /> {/* re-renders every time count changes, even though it doesn't use count at all */}
</div>
);
}
Clicking the button re-renders App, and by default that re-renders ExpensiveList too — even though nothing about ExpensiveList's own props changed. For a cheap component, that's invisible. For something genuinely expensive to render (a large table, a chart, a component doing heavy calculation inline), it's wasted work on every unrelated state change elsewhere in the tree. React gives you three closely related tools to avoid that: React.memo, useMemo, and useCallback.
React.memo: skip a re-render when props haven't changed
React.memo wraps a component and tells React to skip re-rendering it if its props are shallowly equal to what they were last time:
import { memo } from "react";
const ExpensiveList = memo(function ExpensiveList({ items }) {
console.log("ExpensiveList rendered");
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
});
With ExpensiveList wrapped in memo, it only re-renders when its items prop actually changes (by reference) — a count update in a sibling or parent that doesn't touch items no longer re-renders it at all. The comparison memo performs by default is shallow: primitives are compared by value, but objects, arrays, and functions are compared by reference — which is exactly why React.memo alone often isn't enough, and is why useMemo/useCallback exist alongside it.
useMemo: memoize an expensive calculation
useMemo caches the result of a calculation between renders, only recomputing it when one of its listed dependencies changes:
import { useMemo, useState } from "react";
function ProductList({ products, searchTerm }) {
const filteredProducts = useMemo(() => {
console.log("Filtering products..."); // only logs when products or searchTerm actually change
return products.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [products, searchTerm]);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Without useMemo, .filter(...) would re-run on every render of ProductList — including one triggered by something in a parent that has nothing to do with products or searchTerm at all. useMemo re-runs the calculation only when a dependency in its array has actually changed, and returns the previously cached result otherwise — the same dependency-array mental model as useEffect, just used to cache a value instead of running a side effect.
useCallback: memoize a function reference
Every render of a component creates brand-new function instances for anything defined inside it — including event handlers passed as props. That matters specifically when the function is passed to a memo-wrapped child, since a new function reference on every render defeats memo's shallow comparison even though the function's behavior never changed:
import { useCallback, useState } from "react";
function App() {
const [count, setCount] = useState(0);
const [items, setItems] = useState(["Apple", "Banana"]);
// Without useCallback, this is a brand-new function on every render of App
const handleAddItem = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []); // no dependencies — setItems's updater form never needs the old items directly
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ItemAdder onAdd={handleAddItem} />
</div>
);
}
const ItemAdder = memo(function ItemAdder({ onAdd }) {
console.log("ItemAdder rendered");
return <button onClick={() => onAdd("Cherry")}>Add Cherry</button>;
});
useCallback(fn, deps) returns the same function reference across renders as long as deps hasn't changed — so ItemAdder, wrapped in memo, correctly sees an unchanged onAdd prop when only count changes, and skips re-rendering. Without useCallback here, handleAddItem would be a new function every time App rendered, ItemAdder's onAdd prop would look "different" by reference on every render, and memo would re-render it anyway — silently defeating the whole optimization.
A concrete before/after example
Before — a parent's unrelated state update re-renders an expensive child on every keystroke, because neither the child nor its callback prop is memoized:
function App() {
const [query, setQuery] = useState("");
const [items] = useState(() =>
Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `Item ${i}` }))
);
function handleSelect(id) {
console.log("Selected", id);
}
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ExpensiveList items={items} onSelect={handleSelect} />
</div>
);
}
function ExpensiveList({ items, onSelect }) {
console.log("ExpensiveList rendered"); // logs on every keystroke in the input above
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>{item.name}</li>
))}
</ul>
);
}
Every keystroke in <input> updates query, re-renders App, and — since ExpensiveList is a plain component receiving a brand-new handleSelect function reference every time — re-renders all 5,000 list items along with it, even though nothing about the list itself changed.
After — memoizing the child component and its callback prop breaks that unnecessary link:
function App() {
const [query, setQuery] = useState("");
const [items] = useState(() =>
Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `Item ${i}` }))
);
const handleSelect = useCallback((id) => {
console.log("Selected", id);
}, []);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ExpensiveList items={items} onSelect={handleSelect} />
</div>
);
}
const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
console.log("ExpensiveList rendered"); // now logs only once, on initial mount
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>{item.name}</li>
))}
</ul>
);
});
Three things had to line up for this to actually work: ExpensiveList had to be wrapped in memo, its items prop had to already be a stable reference (it is here, since items itself never changes after the initial useState), and its onSelect prop had to be memoized with useCallback — skipping any one of the three would have left the child re-rendering on every keystroke regardless.
When not to over-optimize
memo, useMemo, and useCallback are not free — each one adds a comparison (or a cache lookup) on every render, plus a small amount of memory to hold the cached value or function. For a cheap component or a trivial calculation, that overhead can genuinely cost more than the render it was meant to avoid, and it adds a layer of indirection that makes the code harder to read for no measurable benefit.
A reasonable approach: write plain, unmemoized components first. Reach for these tools specifically when a component is measurably expensive to render (a large list, a chart, a heavy calculation) and is re-rendering more often than its own output actually changes — and confirm that with the React DevTools Profiler rather than guessing. Sprinkling useMemo/useCallback on every value and function "just in case" is a common beginner habit that tends to make code harder to read without a measurable performance win to show for it.
| Tool | Memoizes | Use it when |
|---|---|---|
React.memo |
A whole component's render output | The component is expensive to render and its props often stay the same across parent re-renders |
useMemo |
The result of a calculation | The calculation itself is measurably expensive (large loops, heavy filtering/sorting) |
useCallback |
A function reference | The function is passed as a prop to a memo-wrapped child (or is itself a dependency of another hook) |
Common mistakes
- Wrapping every component in
memoand every function inuseCallbackby default, regardless of whether either is actually expensive — adding overhead and indirection with no measured benefit. - Using
useCallback/useMemobut still passing a new object or array literal as a prop elsewhere (<Child options={{ a: 1 }} />inline) — the memoized function is stable, but a fresh inline object still breaks amemo-wrapped child's shallow prop comparison every render. - Forgetting that
React.memo's default comparison is shallow — a prop that's an object or array will look "changed" on every render unless it's itself memoized (withuseMemo) or genuinely the same reference. - Reaching for these APIs to fix a performance problem that hasn't actually been measured — profile first (React DevTools' Profiler tab) and target the component that's actually slow, rather than optimizing by guesswork.