Why Memoization Exists in React
Every time a component re-renders, the entire function body executes again. Every variable is recalculated. Every function is recreated. Every object is a new reference.
function ProductList({ products, tax }) {
// Re-created on EVERY render:
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
};
// Re-computed on EVERY render:
const total = calculateTotal(products);
// New function reference on EVERY render:
const handleSort = () => { /* ... */ };
return <div>{total}</div>;
}
Most of the time, this is fine. JavaScript is fast. Recreating a function or computing a value takes microseconds. But there are two cases where it matters:
- Expensive computations โ sorting 10,000 items, complex math, parsing large data
- Referential equality โ child components use
React.memo, or values are useEffect/useMemo dependencies
useMemo and useCallback solve these two problems respectively.
useMemo: Memoizing Values
useMemo caches the result of a computation and only recalculates when its dependencies change.
Syntax
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
- First argument: a function that returns the value to cache
- Second argument: dependency array โ recalculates when any dependency changes
- Returns: the cached value (or newly computed value if deps changed)
Example: Expensive Filtering
function ProductCatalog({ products, searchQuery, category }) {
// Without useMemo: filters 10,000 products on EVERY render
// With useMemo: only re-filters when products, searchQuery, or category changes
const filteredProducts = useMemo(() => {
console.log('Filtering products...'); // See when it actually runs
return products
.filter(p => p.category === category)
.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
}, [products, searchQuery, category]);
return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name} โ ${p.price}</li>
))}
</ul>
);
}
Example: Derived Data
function Dashboard({ transactions }) {
const stats = useMemo(() => {
const total = transactions.reduce((sum, t) => sum + t.amount, 0);
const avg = total / transactions.length || 0;
const max = Math.max(...transactions.map(t => t.amount), 0);
const min = Math.min(...transactions.map(t => t.amount), 0);
const byCategory = transactions.reduce((acc, t) => {
acc[t.category] = (acc[t.category] || 0) + t.amount;
return acc;
}, {});
return { total, avg, max, min, byCategory };
}, [transactions]);
return (
<div>
<p>Total: ${stats.total.toFixed(2)}</p>
<p>Average: ${stats.avg.toFixed(2)}</p>
<p>Range: ${stats.min} โ ${stats.max}</p>
</div>
);
}
How useMemo Works
Render 1: useMemo(fn, [A, B])
-> Calls fn(), stores result as cache_1
-> Returns cache_1
Render 2: useMemo(fn, [A, B]) (deps unchanged)
-> Skips fn()
-> Returns cache_1 (same cached value)
Render 3: useMemo(fn, [A, C]) (B changed to C)
-> Calls fn(), stores result as cache_2
-> Returns cache_2
Render 4: useMemo(fn, [A, C]) (deps unchanged)
-> Skips fn()
-> Returns cache_2
useCallback: Memoizing Functions
useCallback caches a function definition and returns the same reference as long as dependencies haven't changed.
Syntax
const memoizedFn = useCallback(() => {
doSomething(a, b);
}, [a, b]);
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). The difference is semantic โ useCallback is for functions, useMemo is for values.
Why Function References Matter
// Parent re-renders -> new handleDelete function -> Child re-renders (even with memo)
function Parent() {
const [count, setCount] = useState(0);
// New function reference every render
const handleDelete = (id) => {
console.log('Delete', id);
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedChild onDelete={handleDelete} />
{/* MemoizedChild re-renders because handleDelete is a new reference */}
</div>
);
}
const MemoizedChild = React.memo(function Child({ onDelete }) {
console.log('Child rendered');
return <button onClick={() => onDelete(1)}>Delete</button>;
});
The Fix
function Parent() {
const [count, setCount] = useState(0);
// Same function reference across renders (unless deps change)
const handleDelete = useCallback((id) => {
console.log('Delete', id);
}, []); // No dependencies โ function never changes
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedChild onDelete={handleDelete} />
{/* MemoizedChild skips re-render because handleDelete is the same reference */}
</div>
);
}
useCallback with Dependencies
function SearchPage() {
const [query, setQuery] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
// Recreated only when sortOrder changes
const handleSearch = useCallback((searchQuery) => {
fetch(`/api/search?q=${searchQuery}&sort=${sortOrder}`)
.then(res => res.json())
.then(data => console.log(data));
}, [sortOrder]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
<SearchResults onSearch={handleSearch} query={query} />
</div>
);
}
When to Use (Real Cases)
Case 1: React.memo Child with Callback Props
This is the most common legitimate use case. Without useCallback, React.memo on the child is useless because the function prop changes every render.
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}, []);
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'completed': return todos.filter(t => t.done);
default: return todos;
}
}, [todos, filter]);
return (
<div>
<FilterBar filter={filter} onFilterChange={setFilter} />
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
}
// React.memo skips re-render if props haven't changed
const TodoItem = React.memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log('TodoItem render:', todo.id);
return (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>X</button>
</div>
);
});
Case 2: Dependency of useEffect
When a function is used in a useEffect dependency array, useCallback prevents the effect from re-running unnecessarily.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
// Without useCallback: new function every render -> effect re-runs every render
const connect = useCallback(() => {
const connection = createConnection(roomId);
connection.on('message', msg => {
setMessages(prev => [...prev, msg]);
});
connection.connect();
return connection;
}, [roomId]);
useEffect(() => {
const connection = connect();
return () => connection.disconnect();
}, [connect]); // Only re-runs when connect changes (when roomId changes)
return <MessageList messages={messages} />;
}
Case 3: Expensive List Operations
function DataGrid({ rows, columns }) {
// Expensive: processes 10,000 rows
const processedRows = useMemo(() => {
return rows.map(row => ({
...row,
cells: columns.map(col => ({
value: col.accessor(row),
formatted: col.format ? col.format(col.accessor(row)) : col.accessor(row),
})),
}));
}, [rows, columns]);
const sortedRows = useMemo(() => {
return [...processedRows].sort((a, b) => {
// complex multi-column sort
return a.cells[0].value.localeCompare(b.cells[0].value);
});
}, [processedRows]);
return (
<table>
<tbody>
{sortedRows.map(row => (
<tr key={row.id}>
{row.cells.map((cell, i) => (
<td key={i}>{cell.formatted}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
When NOT to Use (Antipatterns)
Premature memoization is the most common antipattern. Memoization has overhead โ React must store the previous value, compare dependencies, and manage the cache. For cheap operations, this overhead can exceed the cost of just recalculating.
Antipattern 1: Memoizing Cheap Operations
// UNNECESSARY โ string concatenation is trivially fast
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Just calculate it:
const fullName = `${firstName} ${lastName}`;
// UNNECESSARY โ simple arithmetic
const total = useMemo(() => price * quantity, [price, quantity]);
// Just calculate it:
const total = price * quantity;
Antipattern 2: useCallback Without React.memo
// POINTLESS โ Child is not memoized, so it re-renders anyway
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
// Child re-renders because Parent re-rendered, not because of the prop reference
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
}
Without React.memo on Child:
Parent re-renders
-> Child re-renders (regardless of prop references)
-> useCallback was wasted effort
With React.memo on Child:
Parent re-renders
-> React.memo checks: did props change?
-> onClick is same reference (useCallback) -> props unchanged
-> Child SKIPS re-render
Antipattern 3: Memoizing Everything "Just in Case"
// DON'T do this โ adds complexity with no benefit
function OverMemoized() {
const [name, setName] = useState('');
// Unnecessary โ inline functions in JSX are fine for cheap operations
const handleChange = useCallback((e) => {
setName(e.target.value);
}, []);
// Unnecessary โ simple transform
const displayName = useMemo(() => name.toUpperCase(), [name]);
// Unnecessary โ JSX is already cheap to compute
const input = useMemo(() => (
<input value={name} onChange={handleChange} />
), [name, handleChange]);
return <div>{input}<p>{displayName}</p></div>;
}
// CLEAN โ no memoization needed
function Clean() {
const [name, setName] = useState('');
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<p>{name.toUpperCase()}</p>
</div>
);
}
Decision Flowchart
Should I memoize?
Is the computation expensive?
(sorting 1000+ items, complex math, heavy processing)
|
YES -> useMemo
NO -> Is a memoized child receiving this as a prop?
|
YES -> Is it a function? -> useCallback
Is it an object/array? -> useMemo
NO -> Don't memoize. Just calculate it.
Dependency Array Gotchas
Objects and Arrays Create New References
function SearchPage({ defaultFilters }) {
const [query, setQuery] = useState('');
// BUG: defaultFilters is an object โ if parent re-renders,
// it might be a new reference even with the same values
const results = useMemo(() => {
return search(query, defaultFilters);
}, [query, defaultFilters]);
// This re-runs if defaultFilters is a new object (even with same content)
}
// Fix: Destructure to primitive dependencies
function SearchPage({ defaultFilters }) {
const [query, setQuery] = useState('');
const { category, minPrice, maxPrice } = defaultFilters;
const results = useMemo(() => {
return search(query, { category, minPrice, maxPrice });
}, [query, category, minPrice, maxPrice]);
// Only re-runs when actual values change
}
Functions in Dependencies
// BUG: getItems is a new function every render if parent re-renders
function List({ getItems }) {
const items = useMemo(() => getItems(), [getItems]);
// Re-computes every render because getItems is new
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// Fix: Parent should memoize the function
function Parent() {
const getItems = useCallback(() => {
return expensiveItemComputation();
}, []);
return <List getItems={getItems} />;
}
Changing Dependencies Invalidate the Cache
function ExpensiveComponent({ data, sortField, sortDirection, filterFn }) {
// If ANY dependency changes, the entire computation re-runs
const processed = useMemo(() => {
const filtered = data.filter(filterFn); // O(n)
const sorted = [...filtered].sort((a, b) => {
const cmp = a[sortField].localeCompare(b[sortField]);
return sortDirection === 'asc' ? cmp : -cmp;
});
return sorted;
}, [data, sortField, sortDirection, filterFn]);
// Consider: Can you split this into two useMemo calls?
// Filter once (depends on data, filterFn)
// Sort separately (depends on filtered, sortField, sortDirection)
const filtered = useMemo(() => data.filter(filterFn), [data, filterFn]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const cmp = a[sortField].localeCompare(b[sortField]);
return sortDirection === 'asc' ? cmp : -cmp;
});
}, [filtered, sortField, sortDirection]);
// Now changing sortField doesn't re-filter, and changing filterFn doesn't re-sort
// (Though sorted still depends on filtered, so filter changes propagate)
}
Performance Testing
Measuring Without Memoization
function SlowComponent({ items }) {
console.time('filter');
const expensive = items
.filter(item => complexPredicate(item))
.sort((a, b) => complexSort(a, b))
.map(item => transformItem(item));
console.timeEnd('filter');
// If this logs > 1ms and runs frequently, consider useMemo
return <List items={expensive} />;
}
React Profiler
import { Profiler } from 'react';
function App() {
const onRender = (id, phase, actualDuration) => {
console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
};
return (
<Profiler id="ProductList" onRender={onRender}>
<ProductList products={products} />
</Profiler>
);
}
When to Optimize
| Render Time | Action |
|---|---|
| < 1ms | Don't optimize |
| 1-16ms | Consider optimizing if renders are frequent |
| > 16ms | Optimize (causes dropped frames at 60fps) |
| > 100ms | Definitely optimize (user-visible jank) |
Profiling with React DevTools
- Open React DevTools -> Profiler tab
- Click Record
- Interact with your app
- Click Stop
- Look at the flame chart:
- Gray bars = components that didn't re-render
- Colored bars = components that re-rendered (yellow/red = slow)
- Click a component to see why it re-rendered:
- "Props changed" -> check which prop
- "Parent rendered" -> consider React.memo
- "State changed" -> expected, check if state update is necessary
React.memo: The Missing Piece
useCallback and useMemo are only useful for passing to memoized children. React.memo is what actually prevents the child re-render.
// React.memo wraps a component and skips re-render if props are the same
const ExpensiveList = React.memo(function ExpensiveList({ items, onItemClick }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// Custom comparison function (optional)
const ExpensiveList = React.memo(
function ExpensiveList({ items, onItemClick }) {
// ...
},
(prevProps, nextProps) => {
// Return true to SKIP re-render (props are "equal")
// Return false to re-render
return prevProps.items.length === nextProps.items.length
&& prevProps.items.every((item, i) => item.id === nextProps.items[i].id);
}
);
The Complete Pattern
function App() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
// 1. Memoize the filtered list (expensive computation)
const filteredItems = useMemo(() => {
return allItems.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [searchQuery]);
// 2. Memoize the callback (stable reference for memo'd child)
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
return (
<div>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search..."
/>
{/* 3. Memoized child only re-renders when its props actually change */}
<ItemList items={filteredItems} onSelect={handleSelect} />
<ItemDetail id={selectedId} />
</div>
);
}
// 4. React.memo prevents re-render when props are unchanged
const ItemList = React.memo(function ItemList({ items, onSelect }) {
console.log('ItemList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
Without memoization:
Type in search box
-> App re-renders
-> filteredItems recalculated
-> handleSelect is new reference
-> ItemList re-renders (even if items didn't change)
-> ItemDetail re-renders
With memoization:
Type in search box
-> App re-renders
-> filteredItems recalculated only if searchQuery changed
-> handleSelect is same reference (useCallback)
-> ItemList: React.memo checks props
-> items changed? Maybe. If so, re-render.
-> onSelect changed? No (useCallback). Skip if items same.
-> ItemDetail: only re-renders if selectedId changed
useMemo for Stable Object References
Beyond performance, useMemo is useful for creating stable object references for dependency arrays.
function Map({ center, zoom }) {
// Without useMemo: new object every render -> useEffect re-runs every render
const options = useMemo(() => ({
center: { lat: center.lat, lng: center.lng },
zoom,
mapTypeId: 'roadmap',
}), [center.lat, center.lng, zoom]);
useEffect(() => {
const map = initializeMap(options);
return () => map.destroy();
}, [options]); // Only re-initializes when options actually change
return <div id="map" />;
}
Comparison Table
| useMemo | useCallback | |
|---|---|---|
| Returns | Cached value | Cached function |
| Equivalent to | โ | useMemo(() => fn, deps) |
| Use for | Expensive computations, stable objects | Stable function references |
| Pairs with | Any consumer | React.memo children, useEffect deps |
| Overhead | Stores value + compares deps | Stores function + compares deps |
| Cache size | Last value only (not LRU) | Last function only |
Key Takeaways
- useMemo caches values,
useCallbackcaches functions โ both use dependency arrays - Don't memoize by default โ profile first, optimize bottlenecks
- useCallback is only useful when paired with
React.memoor as auseEffectdependency - useMemo is for expensive computations (>1ms) or stable references for dependency arrays
- Objects and arrays in deps create new references every render โ destructure to primitives when possible
- React.memo is the gatekeeper โ without it on the child,
useCallbackon the parent is wasted - Split expensive operations into multiple
useMemocalls with different deps for finer cache granularity - Measure before and after โ use React Profiler and
console.timeto verify your optimization helps - Memoization has overhead โ comparing dependencies and storing cached values isn't free
- The best optimization is often restructuring components so fewer things re-render, not adding memoization everywhere