Last week, I was optimizing a dashboard with 50+ chart components in Virendana Ui. Performance was terrible — every small state update caused the entire dashboard to re-render. The culprit? I was creating new event handler functions on every render, causing React.memo to be completely useless.
"Why is this child component re-rendering? The props look identical in React DevTools..."
The answer is function reference equality. Every render creates new function objects, even if they do the same thing. That's where useCallback becomes essential.
What useCallback Actually Solves
useCallback returns a memoized version of a callback function that only changes when its dependencies change. Think of it as React's way of saying "keep using this same function until I tell you otherwise."
The key insight: In JavaScript, functions are objects, and {} !== {} even if they're identical. React sees new function = new prop = re-render needed.
The Basic Pattern
const memoizedCallback = useCallback(() => {
// Your function logic here
}, [dependency1, dependency2]);
React only recreates the function when values in the dependency array change. Empty array means the function never changes.
Real-World Example: Optimizing List Performance
const TodoList = () => {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
// Without useCallback - creates new function every render
// const handleToggle = (id) => {
// setTodos(prev => prev.map(todo =>
// todo.id === id ? { ...todo, completed: !todo.completed } : todo
// ));
// };
// With useCallback - function stays stable
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []); // Empty deps - function never changes
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
};
const TodoItem = React.memo(({ todo, onToggle, onDelete }) => {
console.log('TodoItem rendered:', todo.id);
return (
<div>
<span>{todo.text}</span>
<button onClick={() => onToggle(todo.id)}>Toggle</button>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
Without useCallback, every filter change would re-render every TodoItem. With it, only the filtered items re-render. In a 1000-item list, this makes the difference between sluggish and smooth.
The Anti-Pattern: Memoizing Everything
// DON'T DO THIS - Unnecessary memoization
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // This function is only used in this component
return <button onClick={handleClick}>Click me</button>;
This is wasteful. useCallback has overhead — you're trading memory for performance that doesn't exist. Only memoize functions that are passed as props to memoized components or used in effect dependencies.
When I Reach for useCallback
After years of React development, I use useCallback in these specific scenarios:
- Props to React.memo components — The most common and effective use case
- useEffect dependencies — Prevents infinite re-runs when effects depend on functions
- Context values — Prevents all consumers from re-rendering unnecessarily
- Large lists with event handlers — Each item gets the same function reference
useCallback vs useMemo: Know the Difference
| Aspect | useCallback | useMemo |
|---|---|---|
| What it memoizes | The function itself | The result of calling a function |
| Returns | Same function reference | Computed value |
| Best for | Event handlers, callbacks | Expensive calculations |
| Example use | onClick handlers for list items | Filtering/sorting large datasets |
Performance Traps I've Fallen Into
Premature optimization is the root of all evil — this applies to useCallback too.
Common mistakes I've made (and debugged):
- Over-memoizing simple functions — The memoization overhead exceeds the benefit
- Wrong dependencies — Function recreates on every render anyway
- Memoizing without React.memo — The child component isn't optimized to skip renders
- Forgetting exhaustive-deps — ESLint will save you here
Advanced Pattern: useCallback with useEffect
One of the most valuable patterns I use regularly — preventing infinite effect loops:
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
// Memoize the fetch function to prevent effect re-runs
const fetchUser = useCallback(async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
}, [userId]); // Only recreate when userId changes
useEffect(() => {
fetchUser();
}, [fetchUser]); // Safe to include in deps now
// ... rest of component
};
Without useCallback, the effect would run on every render because fetchUser is a new function each time. This pattern is essential for data fetching with proper dependency management.
useCallback Mastery Guide
| Concept | Key Insight |
|---|---|
| Primary purpose | Stabilize function references between renders |
| Performance benefit | Prevents unnecessary child re-renders |
| When to use | Props to memoized components, effect dependencies |
| When to avoid | Internal functions, premature optimization |
| Best practices | Pair with React.memo, trust ESLint exhaustive-deps |
useCallback Questions from Code Reviews
When should I use useCallback instead of useMemo?
Use useCallback for memoizing functions themselves, useMemo for memoizing computed values. If you're passing a function as a prop or using it in useEffect dependencies, useCallback is your tool.
Does useCallback actually improve performance?
Only when used correctly with memoized components. Without React.memo on the receiving component, useCallback provides no performance benefit and adds overhead.
Should I memoize every function I pass as a prop?
No. Profile first, optimize second. Most React apps don't need this level of optimization. Focus on components that actually have performance issues.
What happens if I forget dependencies in useCallback?
The function will use stale values from when it was first created. Always use the exhaustive-deps ESLint rule — it catches these bugs before they reach production.
Can useCallback cause memory leaks?
Rarely, but possible if you're capturing large objects in closures. The memoized function keeps references to all variables in its scope. Be mindful of what you're closing over.