Introduction
Performance optimization becomes unavoidable once your React app starts handling real user data and complex interactions. I've debugged enough sluggish dashboards and janky animations to know that understanding useMemo
and useCallback
isn't just nice-to-have knowledge anymore.
These hooks solve specific performance problems by controlling when expensive operations run and when functions get recreated. But here's the thing: they're not magic bullets. Use them wrong, and you might actually make things slower. Use them right, and you'll see noticeable improvements in components that were previously struggling.
This guide covers the practical side of both hooks, including the gotchas I wish someone had told me about earlier.
Understanding React Rendering
React's rendering cycle is pretty straightforward: when state or props change, the component re-renders. This works great for keeping your UI in sync, but it can create performance problems when you have expensive operations or deep component trees.
Every re-render means:
- All functions inside the component get recreated
- All calculations run again
- Child components might re-render unnecessarily
The key insight is that not everything needs to happen on every render. Some calculations only need to run when specific values change. Some functions can be reused between renders if their logic hasn't changed.
That's where memoization comes in. It's essentially a cache that stores the result of an operation and returns the cached version when the inputs haven't changed.
What is useMemo?
useMemo
caches the result of a calculation. Think of it as a smart cache that only recalculates when something it depends on actually changes.
Syntax
const memoizedValue = useMemo(() => {
return expensiveCalculation(dependency1, dependency2);
}, [dependency1, dependency2]);
The function you pass to useMemo
only runs when one of the dependencies in the array changes. Otherwise, it returns the cached result from the previous render.
Real-World Example: Processing Large Datasets
Here's a scenario I run into frequently: filtering and sorting large lists based on user input.
import React, { useMemo, useState } from 'react';
function ProductList({ products }) {
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [category, setCategory] = useState('all');
// Without useMemo, this runs on every render
const processedProducts = useMemo(() => {
console.log('Processing products...'); // You'll see this only when dependencies change
return products
.filter(product => {
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = category === 'all' || product.category === category;
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
switch (sortBy) {
case 'price':
return a.price - b.price;
case 'name':
return a.name.localeCompare(b.name);
default:
return 0;
}
});
}, [products, searchTerm, sortBy, category]);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
{processedProducts.map(product => (
<div key={product.id}>{product.name} - ${product.price}</div>
))}
</div>
);
}
Without useMemo
, the filtering and sorting would happen on every render, even if you just clicked a button that updated some unrelated state. With useMemo
, it only processes when the actual search term, sort option, category, or product list changes.
When useMemo Makes Sense
- Heavy calculations: Mathematical computations, data transformations, or complex filtering
- Creating objects or arrays: Preventing new object references that cause child re-renders
- Derived state: Values calculated from multiple state variables
// Good use case: expensive calculation
const expensiveValue = useMemo(() => {
return heavyProcessing(data);
}, [data]);
// Good use case: preventing object recreation
const chartConfig = useMemo(() => ({
width: 400,
height: 300,
data: processedData
}), [processedData]);
// Bad use case: simple calculation
const simpleValue = useMemo(() => a + b, [a, b]); // Just do: const simpleValue = a + b;
💡 Tip: Use the React DevTools Profiler to identify components that are actually slow before adding useMemo
. Sometimes the performance problem is elsewhere.
What is useCallback?
useCallback
is like useMemo
but for functions. It returns a memoized version of a function that only changes when its dependencies change.
Syntax
const memoizedCallback = useCallback(() => {
doSomething(dependency1, dependency2);
}, [dependency1, dependency2]);
The Child Re-render Problem
Here's the classic scenario where useCallback
shines: preventing child components from re-rendering when they receive the same function prop.
import React, { useState, useCallback, memo } from 'react';
// Child component wrapped in React.memo
const TodoItem = memo(({ todo, onToggle, onDelete }) => {
console.log(`Rendering TodoItem: ${todo.text}`);
return (
<div className="todo-item">
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => onToggle(todo.id)}
>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build an app', completed: false }
]);
const [newTodo, setNewTodo] = useState('');
// Without useCallback, these functions get recreated on every render
// This causes TodoItem to re-render even when the todo data hasn't changed
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []);
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
const addTodo = () => {
if (newTodo.trim()) {
setTodos(prev => [...prev, {
id: Date.now(),
text: newTodo,
completed: false
}]);
setNewTodo('');
}
};
return (
<div>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add new todo..."
/>
<button onClick={addTodo}>Add Todo</button>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
}
In this example, TodoItem
is wrapped with React.memo
, which means it only re-renders when its props actually change. Without useCallback
, the onToggle
and onDelete
functions would be recreated on every render, causing all TodoItem
components to re-render even when their todo data hasn't changed.
Advanced useCallback Pattern: Event Handlers with Dynamic Data
Sometimes you need to pass changing data to a callback while still keeping it stable:
const UserCard = memo(({ user, onUpdate }) => {
return (
<div>
<h3>{user.name}</h3>
<button onClick={() => onUpdate(user.id, { lastViewed: Date.now() })}>
Mark as Viewed
</button>
</div>
);
});
function UserList({ users }) {
const [userStats, setUserStats] = useState({});
// This callback depends on nothing, making it stable across renders
const handleUserUpdate = useCallback((userId, updates) => {
setUserStats(prev => ({
...prev,
[userId]: { ...prev[userId], ...updates }
}));
}, []);
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onUpdate={handleUserUpdate}
/>
))}
</div>
);
}
⚠️ Warning: Don't wrap every function in useCallback
. Only do it when you're passing the function to a memoized child component or when the function has expensive dependencies.
Comparison Table: useMemo vs useCallback
Feature | useMemo | useCallback |
---|---|---|
Purpose | Memoizes a computed value | Memoizes a function reference |
Return Value | The result of the computation | A memoized version of the function |
Dependencies | Recomputes when dependencies change | Recreates function when dependencies change |
Primary Use Case | Expensive calculations, derived state | Stable function references for child props |
Performance Impact | Prevents unnecessary computations | Prevents unnecessary child re-renders |
Common Mistake | Memoizing trivial calculations | Wrapping functions that don't need stability |
Memory Usage | Stores computed values | Stores function references |
Advanced Patterns and Best Practices
Combining Both Hooks
Sometimes you'll use both hooks together for complex optimizations:
function DataVisualization({ rawData, filters }) {
// Expensive data processing
const processedData = useMemo(() => {
return rawData
.filter(item => filters.category === 'all' || item.category === filters.category)
.map(item => ({
...item,
normalizedValue: (item.value - filters.minValue) / (filters.maxValue - filters.minValue)
}));
}, [rawData, filters]);
// Stable event handlers
const handleDataPointClick = useCallback((dataPoint) => {
console.log('Clicked:', dataPoint);
// Handle click logic
}, []);
const handleZoom = useCallback((zoomLevel) => {
// Handle zoom logic
}, []);
return (
<Chart
data={processedData}
onDataPointClick={handleDataPointClick}
onZoom={handleZoom}
/>
);
}
Dependency Array Best Practices
The dependency array is where most bugs happen. Here are the rules I follow:
// ✅ Good: All external dependencies included
const calculation = useMemo(() => {
return complexCalculation(data, userPreferences, apiConfig);
}, [data, userPreferences, apiConfig]);
// ❌ Bad: Missing dependencies (will use stale values)
const calculation = useMemo(() => {
return complexCalculation(data, userPreferences, apiConfig);
}, [data]); // Missing userPreferences and apiConfig
// ✅ Good: Stable dependencies using refs or state setters
const handleSave = useCallback(() => {
saveData(formData);
}, [formData]);
// ✅ Good: Empty dependencies when function doesn't use external values
const createNewItem = useCallback(() => {
return { id: generateId(), createdAt: Date.now() };
}, []); // No external dependencies
📌 Note: ESLint plugin react-hooks/exhaustive-deps
will catch most dependency array mistakes. Install it and trust its warnings.
Custom Hooks with Memoization
Creating custom hooks that properly use memoization:
function useFilteredData(data, filters) {
const filteredData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.filter(item => {
return Object.entries(filters).every(([key, value]) => {
if (value === null || value === undefined || value === '') return true;
return item[key] === value;
});
});
}, [data, filters]);
const handleFilter = useCallback((key, value) => {
return { ...filters, [key]: value };
}, [filters]);
return { filteredData, handleFilter };
}
When NOT to Use These Hooks
Don't Memoize Everything
I used to wrap every calculation in useMemo
thinking it would make everything faster. It doesn't. Memoization has overhead too.
// ❌ Unnecessary memoization
const simpleSum = useMemo(() => a + b, [a, b]);
const isEven = useMemo(() => number % 2 === 0, [number]);
// ✅ Just calculate directly
const simpleSum = a + b;
const isEven = number % 2 === 0;
Don't useCallback Without React.memo
useCallback
only helps when the function is passed to a component that's wrapped in React.memo
. Otherwise, you're just adding complexity for no benefit.
// ❌ useCallback without memo - no performance benefit
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />; // Child is not memoized
}
// ✅ useCallback with memo - actual performance benefit
const Child = memo(({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
});
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <Child onClick={handleClick} />;
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Object Dependencies in Arrays
Objects and arrays in dependency arrays can cause issues because React uses shallow comparison:
// ❌ This will re-run on every render because {} !== {}
const result = useMemo(() => {
return processData(config);
}, [config]); // If config is { threshold: 10 }, it's recreated each render
// ✅ Better: Destructure the specific values you need
const result = useMemo(() => {
return processData(config);
}, [config.threshold, config.sortOrder]);
// ✅ Or memoize the config object itself
const memoizedConfig = useMemo(() => ({
threshold: 10,
sortOrder: 'asc'
}), []);
Pitfall 2: Functions in Dependencies
// ❌ Function recreated every render, breaks memoization
function Component({ data }) {
const processItem = (item) => {
return item.value * 2;
};
const processedData = useMemo(() => {
return data.map(processItem);
}, [data, processItem]); // processItem changes every render!
// ✅ Better: Move function inside useMemo
const processedData = useMemo(() => {
const processItem = (item) => item.value * 2;
return data.map(processItem);
}, [data]);
// ✅ Or use useCallback for the function
const processItem = useCallback((item) => {
return item.value * 2;
}, []);
const processedData = useMemo(() => {
return data.map(processItem);
}, [data, processItem]);
}
Pitfall 3: Over-Engineering Simple Components
// ❌ Over-optimized for a simple component
function SimpleCounter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const doubledCount = useMemo(() => count * 2, [count]);
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubledCount}</p>
<button onClick={increment}>+</button>
</div>
);
}
// ✅ Simple and clear
function SimpleCounter() {
const [count, setCount] = useState(0);
const doubledCount = count * 2;
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubledCount}</p>
<button onClick={() => setCount(prev => prev + 1)}>+</button>
</div>
);
}
Measuring Performance Impact
Before optimizing, measure what's actually slow:
function ExpensiveComponent({ data }) {
// Add performance markers to see the impact
const processedData = useMemo(() => {
console.time('data-processing');
const result = heavyDataProcessing(data);
console.timeEnd('data-processing');
return result;
}, [data]);
return <DataVisualization data={processedData} />;
}
Use React DevTools Profiler to see which components are re-rendering and how long they take. Focus your optimization efforts on the components that are actually causing problems.
⚠️ Warning: Premature optimization can make your code harder to read and maintain. Profile first, optimize second.
Real-World Performance Patterns
Pattern 1: Stable References for Third-Party Libraries
Many third-party libraries (like chart libraries) re-initialize when they receive new function props:
function ChartComponent({ data, onDataSelect }) {
const chartOptions = useMemo(() => ({
responsive: true,
plugins: {
legend: { position: 'top' },
tooltip: { enabled: true }
}
}), []);
const handleChartClick = useCallback((event, elements) => {
if (elements.length > 0) {
const dataIndex = elements[0].index;
onDataSelect(data[dataIndex]);
}
}, [data, onDataSelect]);
return (
<Chart
data={data}
options={chartOptions}
onClick={handleChartClick}
/>
);
}
Pattern 2: Optimizing List Rendering
When rendering large lists, stable functions prevent unnecessary re-renders of individual items:
const ListItem = memo(({ item, onEdit, onDelete }) => {
return (
<div className="list-item">
<span>{item.name}</span>
<button onClick={() => onEdit(item.id)}>Edit</button>
<button onClick={() => onDelete(item.id)}>Delete</button>
</div>
);
});
function ItemList({ items }) {
const [editingId, setEditingId] = useState(null);
const handleEdit = useCallback((id) => {
setEditingId(id);
}, []);
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
return (
<div>
{items.map(item => (
<ListItem
key={item.id}
item={item}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
);
}
Testing Your Optimizations
Using React DevTools
- Open React DevTools
- Go to the Profiler tab
- Record interactions with your component
- Look for components that re-render unnecessarily or take a long time
Simple Performance Test
function usePerformanceTest(name) {
const startTime = performance.now();
useEffect(() => {
const endTime = performance.now();
console.log(`${name} render time: ${endTime - startTime}ms`);
});
}
function OptimizedComponent() {
usePerformanceTest('OptimizedComponent');
// Your component logic here
}
Conclusion
useMemo
and useCallback
are tools for solving specific performance problems, not blanket solutions to make everything faster. The key is understanding when your components are actually slow and why.
Use useMemo
when you have expensive calculations that don't need to run on every render. Use useCallback
when you're passing functions to memoized child components. Always measure the impact of your optimizations.
The best optimization is often simplifying your component structure or moving expensive operations outside of render cycles entirely. These hooks are your second line of defense when good component design isn't enough.
Start with profiling, identify the real bottlenecks, then apply these optimizations thoughtfully. Your users will notice the difference in components that actually need it.