React’s useMemo and useCallback are performance optimization hooks—emphasis on optimization. They’re not magic bullets that make your app fast, but surgical tools for addressing specific performance bottlenecks. With React 19’s compiler optimizations and better default behavior, understanding when and how to use these hooks becomes even more crucial (and thankfully, less frequent).
In this guide, we’ll explore when memoization actually helps, when it hurts, and how React 19 changes the game. By the end, you’ll know exactly when to reach for these hooks and when to let React’s built-in optimizations do the heavy lifting.
What Memoization Actually Solves
Before diving into the hooks themselves, let’s clarify what problems memoization addresses in React:
- Expensive calculations: When a component re-renders, any expensive computations in the render function run again—even if the inputs haven’t changed.
- Reference equality issues: New objects and functions created on every render can trigger unnecessary re-renders in child components that depend on reference equality.
- Dependency stability: Hooks like
useEffectdepend on reference equality to determine when to re-run.
Think of memoization as caching—you’re storing a result and reusing it until the inputs change. But like all caches, it comes with overhead.
useMemo: Caching Expensive Calculations
useMemo caches the result of a computation and only recalculates when its dependencies change.
The Basic Pattern
import { useMemo } from 'react';
function ExpensiveComponent({ items, searchTerm }: Props) {
const filteredItems = useMemo(() => {
console.log('Filtering items...'); // This should only run when dependencies change
return items
.filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
}, [items, searchTerm]);
return (
<div>
{filteredItems.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}Without useMemo, this filtering and sorting would happen on every render—even if items and searchTerm haven’t changed. With thousands of items, that’s wasteful.
When useMemo Actually Helps
Here are the scenarios where useMemo provides real value:
1. Computationally expensive operations
function DataAnalyzer({ dataset }: { dataset: number[] }) {
// ✅ Good: Expensive statistical calculation
const statistics = useMemo(() => {
const sorted = [...dataset].sort((a, b) => a - b);
const mean = dataset.reduce((sum, n) => sum + n, 0) / dataset.length;
const median = sorted[Math.floor(sorted.length / 2)];
const standardDeviation = Math.sqrt(
dataset.reduce((sum, n) => sum + Math.pow(n - mean, 2), 0) / dataset.length,
);
return { mean, median, standardDeviation };
}, [dataset]);
return <div>{/* Render statistics */}</div>;
}2. Complex data transformations
function UserList({ users, filters }: Props) {
// ✅ Good: Complex filtering and transformation
const processedUsers = useMemo(() => {
return users
.filter((user) => {
if (filters.department && user.department !== filters.department) return false;
if (filters.active !== undefined && user.active !== filters.active) return false;
if (filters.searchTerm) {
const searchLower = filters.searchTerm.toLowerCase();
return (
user.name.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower)
);
}
return true;
})
.map((user) => ({
...user,
displayName: `${user.name} (${user.department})`,
initials: user.name
.split(' ')
.map((part) => part[0])
.join(''),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [users, filters]);
return <div>{/* Render processed users */}</div>;
}3. Stabilizing object references for child components
interface ChartConfig {
type: 'line' | 'bar';
colors: string[];
animations: boolean;
}
function Dashboard({ data, chartType }: Props) {
// ✅ Good: Prevents unnecessary re-renders of expensive Chart component
const chartConfig: ChartConfig = useMemo(
() => ({
type: chartType,
colors: ['#ff6384', '#36a2eb', '#ffce56'],
animations: true,
}),
[chartType],
);
return <ExpensiveChart data={data} config={chartConfig} />;
}
const ExpensiveChart = React.memo(({ data, config }: ChartProps) => {
// Expensive rendering logic here
console.log('Chart re-rendering'); // Should only log when data or config changes
return <div>{/* Complex chart rendering */}</div>;
});When useMemo Doesn’t Help (and Can Hurt)
1. Simple calculations
function UserCard({ user }: { user: User }) {
// ❌ Bad: The memoization overhead exceeds the computation cost
const displayName = useMemo(() => {
return `${user.firstName} ${user.lastName}`;
}, [user.firstName, user.lastName]);
// ✅ Better: Just do it inline
const displayName = `${user.firstName} ${user.lastName}`;
}2. Always-changing dependencies
function Timer() {
const [time, setTime] = useState(Date.now());
// ❌ Bad: time changes constantly, so memoization never helps
const formattedTime = useMemo(() => {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(time));
}, [time]);
// ✅ Better: Just format it directly
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(time));
}3. Primitive values
function Counter({ count }: { count: number }) {
// ❌ Bad: Primitives are already "memoized" by reference
const doubled = useMemo(() => count * 2, [count]);
// ✅ Better: Direct calculation
const doubled = count * 2;
}useCallback: Stabilizing Function References
useCallback memoizes a function definition—not its result. This is primarily useful for preventing unnecessary re-renders of child components that rely on function reference equality.
The Basic Pattern
function TodoList({ todos }: { todos: Todo[] }) {
const [editingId, setEditingId] = useState<string | null>(null);
// ✅ Function reference stays stable unless editingId changes
const handleToggleEdit = useCallback((id: string) => {
setEditingId((current) => (current === id ? null : id));
}, []);
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggleEdit={handleToggleEdit}
isEditing={editingId === todo.id}
/>
))}
</div>
);
}
const TodoItem = React.memo(({ todo, onToggleEdit, isEditing }: TodoItemProps) => {
console.log(`Rendering TodoItem ${todo.id}`); // Should minimize logs
return (
<div>
<span>{todo.text}</span>
<button onClick={() => onToggleEdit(todo.id)}>{isEditing ? 'Cancel' : 'Edit'}</button>
</div>
);
});Without useCallback, handleToggleEdit would be a new function on every render, causing all TodoItem components to re-render even when their todo prop hasn’t changed.
Real-World useCallback Patterns
1. Event handlers for memoized components
function UserManagement({ users }: { users: User[] }) {
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
// ✅ Stable function reference prevents unnecessary re-renders
const handleUserSelect = useCallback((userId: string, selected: boolean) => {
setSelectedUsers((prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(userId);
} else {
newSet.delete(userId);
}
return newSet;
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedUsers(new Set(users.map((u) => u.id)));
}, [users]);
return (
<div>
<button onClick={handleSelectAll}>Select All</button>
{users.map((user) => (
<UserCard
key={user.id}
user={user}
selected={selectedUsers.has(user.id)}
onSelect={handleUserSelect}
/>
))}
</div>
);
}2. Callback functions with external dependencies
function SearchResults({ searchTerm, onResultClick }: Props) {
const [results, setResults] = useState<SearchResult[]>([]);
// ✅ Function depends on searchTerm, so it's included in dependencies
const handleResultClick = useCallback(
(result: SearchResult) => {
// Log the search context with the click
analytics.track('search_result_clicked', {
searchTerm,
resultId: result.id,
resultType: result.type,
});
onResultClick(result);
},
[searchTerm, onResultClick],
);
return (
<div>
{results.map((result) => (
<ResultCard key={result.id} result={result} onClick={handleResultClick} />
))}
</div>
);
}When useCallback Becomes Pointless
1. No memoized children
function BadExample({ items }: { items: Item[] }) {
// ❌ Bad: No memoized children, so stable reference doesn't matter
const handleClick = useCallback((id: string) => {
console.log(`Clicked ${id}`);
}, []);
return (
<div>
{items.map((item) => (
// Non-memoized component will re-render anyway
<ItemCard key={item.id} item={item} onClick={() => handleClick(item.id)} />
))}
</div>
);
}2. Functions that always change
function BadCounter() {
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(1);
// ❌ Bad: Dependencies change frequently, negating memoization benefits
const handleIncrement = useCallback(() => {
setCount((prev) => prev + multiplier);
console.log(`New count will be: ${count + multiplier}`);
}, [count, multiplier]); // Both values change often!
}React 19: The Game Changer
React 19 introduces significant changes that affect when you need these hooks:
React Compiler Optimizations
The React Compiler in React 19 can automatically optimize many cases where you’d previously need manual memoization:
// React 19 can optimize this automatically
function AutoOptimized({ items, filter }: Props) {
// The compiler may automatically memoize this transformation
const filteredItems = items
.filter((item) => item.category === filter)
.sort((a, b) => a.name.localeCompare(b.name));
// The compiler may automatically stabilize this function reference
const handleItemClick = (id: string) => {
console.log(`Clicked item ${id}`);
};
return (
<div>
{filteredItems.map((item) => (
<ItemCard key={item.id} item={item} onClick={() => handleItemClick(item.id)} />
))}
</div>
);
}When to Still Use Manual Memoization
Even with React 19’s improvements, manual memoization is still valuable in specific cases:
1. Cross-component boundaries the compiler can’t see
// ✅ Still valuable: External API calls or complex integrations
function WeatherWidget({ location }: { location: string }) {
const weatherData = useMemo(() => {
// Complex weather calculation that the compiler can't optimize
return calculateWeatherMetrics(location);
}, [location]);
return <div>{/* weather display */}</div>;
}2. Expensive third-party library integrations
function ChartComponent({ data }: { data: ChartData }) {
// ✅ Still needed: Third-party library optimizations
const chartOptions = useMemo(() => {
return expensiveChartLibrary.generateConfig(data);
}, [data]);
return <ExpensiveChart options={chartOptions} />;
}Measuring Performance Impact
Before adding memoization, measure the performance impact. React DevTools Profiler is your best friend here:
function ProfiledComponent({ data }: Props) {
// Add console.time to measure expensive operations
console.time('data-processing');
const processedData = useMemo(() => {
const result = expensiveDataProcessing(data);
console.timeEnd('data-processing');
return result;
}, [data]);
return <div>{/* component content */}</div>;
}Best Practices and Guidelines
The Memoization Decision Tree
Is the computation actually expensive? (> 5ms consistently)
- If no: Don’t memoize
- If yes: Continue
Do the dependencies change frequently?
- If yes: Memoization won’t help much
- If no: Continue
Are you solving a real performance problem?
- If no: Don’t prematurely optimize
- If yes: Memoize
Dependency Array Best Practices
function GoodExample({ user, settings }: Props) {
// ✅ Good: All dependencies included
const userPreferences = useMemo(() => {
return {
theme: settings.theme,
language: user.preferredLanguage,
notifications: settings.notifications && user.allowNotifications,
};
}, [settings.theme, settings.notifications, user.preferredLanguage, user.allowNotifications]);
// ✅ Good: No dependencies needed (uses setter function)
const handleToggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
}Common Gotchas
1. Missing dependencies
function BuggyComponent({ items, filter }: Props) {
const [sortOrder, setSortOrder] = useState('asc');
// ❌ Bug: Missing sortOrder in dependencies
const sortedItems = useMemo(() => {
return items
.filter((item) => item.type === filter)
.sort((a, b) => {
return sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
});
}, [items, filter]); // Missing sortOrder!
}2. Objects as dependencies
function TrickyComponent({ config }: { config: Config }) {
// ❌ Potentially problematic: config object might be new every render
const processedData = useMemo(() => {
return processData(config);
}, [config]);
// ✅ Better: Depend on specific properties
const processedData = useMemo(() => {
return processData(config);
}, [config.apiUrl, config.timeout, config.retries]);
}Testing Memoized Components
Memoized components require special testing considerations:
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
describe('MemoizedComponent', () => {
it('should not recalculate when irrelevant props change', () => {
const expensiveCalculation = vi.fn(() => 'calculated result');
function TestComponent({ data, irrelevantProp }: Props) {
const result = useMemo(() => expensiveCalculation(data), [data]);
return <div>{result}</div>;
}
const { rerender } = render(<TestComponent data="test" irrelevantProp="value1" />);
expect(expensiveCalculation).toHaveBeenCalledTimes(1);
// Re-render with different irrelevant prop
rerender(<TestComponent data="test" irrelevantProp="value2" />);
// Should not call expensive calculation again
expect(expensiveCalculation).toHaveBeenCalledTimes(1);
});
});When Not to Worry About Memoization
React 19’s improvements mean you can often skip manual optimization in these cases:
- Simple components with minimal computation
- Components that re-render infrequently
- Development builds (focus on production performance)
- Small lists (< 100 items typically)
- Apps without performance issues
Remember: premature optimization is the root of all evil. Start with clean, readable code. Add memoization when you have actual performance problems, not imaginary ones.