Introduction
Every time a component re-renders, all functions declared inside it are recreated with new references. Usually this is harmless, but when you pass functions as props to memoized child components or list them as dependencies in effects, new references cause unnecessary re-renders or effect re-runs. The useCallback hook caches a function definition so it maintains the same reference across renders.
Key Concepts
- Function Identity: In JavaScript, two identical function declarations are different objects.
(() => {}) !== (() => {}). - useCallback: Caches a function and returns the same reference as long as the dependencies have not changed.
- React.memo: A higher-order component that skips re-rendering when props have not changed by reference.
useCallbackensures callback props maintain stable references. - Relationship to useMemo:
useCallback(fn, deps)is equivalent touseMemo(() => fn, deps).
Real World Context
A data table component has 1,000 rows. Each row receives an onDelete callback. Without useCallback, every re-render of the parent creates a new onDelete function, which means every row re-renders even if the data has not changed. With useCallback and React.memo, only affected rows re-render.
Deep Dive
Basic Usage
tsximport { useCallback, memo, useState } from 'react'; const TodoItem = memo(function TodoItem({ todo, onToggle }: { todo: { id: number; text: string; done: boolean }; onToggle: (id: number) => void; }) { console.log('Rendering:', todo.text); return ( <li onClick={() => onToggle(todo.id)}> {todo.done ? '\u2705' : '\u2B1C'} {todo.text} </li> ); }); function TodoList() { const [todos, setTodos] = useState([ { id: 1, text: 'Learn React', done: false }, { id: 2, text: 'Build an app', done: false }, ]); const [filter, setFilter] = useState(''); // Stable reference: TodoItem won't re-render when filter changes const handleToggle = useCallback((id: number) => { setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo )); }, []); return ( <div> <input value={filter} onChange={e => setFilter(e.target.value)} /> <ul> {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} onToggle={handleToggle} /> ))} </ul> </div> ); }
useCallback for Effect Dependencies
tsxfunction SearchPage({ apiBase }: { apiBase: string }) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); // Stable function reference for the effect dependency const fetchResults = useCallback(async (searchQuery: string) => { const res = await fetch(`${apiBase}/search?q=${searchQuery}`); return res.json(); }, [apiBase]); useEffect(() => { if (!query) return; fetchResults(query).then(setResults); }, [query, fetchResults]); // fetchResults only changes when apiBase changes return <input value={query} onChange={e => setQuery(e.target.value)} />; }
useMemo vs useCallback
tsx// These are equivalent: const memoizedFn = useCallback((x) => x * 2, []); const memoizedFn = useMemo(() => (x) => x * 2, []); // But they cache different things: // useCallback caches the function itself // useMemo caches the return value of the factory function
Common Pitfalls
- Using useCallback without React.memo —
useCallbackalone does nothing for performance. It only helps when the receiving component is wrapped inReact.memo(or the function is used as an effect dependency). Withoutmemo, the child re-renders regardless of prop reference stability. - Adding too many dependencies — If your callback depends on frequently changing values,
useCallbackwill recreate the function on most renders anyway. Consider using functional state updates to reduce dependencies.
Best Practices
- Pair useCallback with React.memo — These two tools work together. Use
useCallbackon the callback andReact.memoon the child that receives it. - Prefer functional updates inside callbacks — Using
setItems(prev => ...)inside auseCallbackmeans the callback does not need the current state in its dependency array, keeping it stable.
Summary
useCallbackcaches a function definition to maintain referential stability across re-renders.- It prevents unnecessary re-renders when paired with
React.memoon child components. - Use functional state updates inside callbacks to minimize dependencies and maximize cache hits.
Code Examples
tsx
import { useCallback, memo } from 'react';
const ExpensiveChild = memo(function ExpensiveChild({
onClick,
}: {
onClick: () => void;
}) {
console.log('ExpensiveChild rendered');
return <button onClick={onClick}>Click me</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<p>Count: {count}</p>
<ExpensiveChild onClick={handleClick} />
</div>
);
}