How useCallback works and why is it needed
useCallback is a React hook that memoizes a function, returning the same reference across re-renders unless its dependencies change.
Theory
TL;DR
- Each render creates new function instances; two identical arrow functions are not
===equal useCallbackcaches the function and returns the cached version if deps match- Main use case: callbacks passed to
React.memochildren to skip unnecessary re-renders - Without a memoized child receiving it,
useCallbackadds overhead with no render savings - Rule: profile with React DevTools Profiler first, then add
useCallbackonly where it actually helps
Quick example
import { useState, useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// Without useCallback: new function every render -> Button always re-renders
// const handleClick = () => setCount(c => c + 1);
// With useCallback: same ref unless deps change -> Button skips re-render
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // empty deps = never recreated
return (
<>
<Button onClick={handleClick} />
<button onClick={() => setOther(o => o + 1)}>Change other</button>
</>
);
}
const Button = React.memo(({ onClick }) => {
console.log('Button render'); // skips when handleClick ref is stable
return <button onClick={onClick}>Click</button>;
});When other changes, Parent re-renders but Button skips because handleClick holds the same reference.
Why function references matter
Every render call creates new function objects. Two arrow functions with identical bodies are still different references:
const a = () => {};
const b = () => {};
console.log(a === b); // falseReact.memo does a shallow prop comparison. A new function reference triggers a child re-render even though nothing functionally changed. useCallback breaks that cycle by returning the cached instance when deps match.
When to use
Use it when:
- Passing a callback to a
React.memo-wrapped child (the primary scenario) - A function is listed as a dependency in
useEffectoruseMemo- a new reference every render causes infinite loops - Handlers on items in large memoized lists
Skip it when:
- No memoized child is receiving the function
- Dependencies change on every render anyway (the hook recreates regardless)
- It's an internal utility used only inside the component, not passed as a prop
How React stores callbacks internally
React keeps memoized callbacks in the fiber node's memoizedState linked list. On each re-render, it compares deps using Object.is per element (shallow, not deep). If all elements match, React returns the cached closure without allocating a new one. If any dep changed, the cached entry is replaced.
I've seen teams add useCallback everywhere as a reflex, expecting a global speed boost. It doesn't work that way. The savings come from child renders you skip, not from avoiding function allocation in isolation.
useCallback vs useMemo
| useCallback | useMemo | |
|---|---|---|
| Returns | A function | A computed value |
| Typical use | Stable callback for a child or effect dep | Result of an expensive calculation |
| Example | useCallback(() => api.save(id), [id]) | useMemo(() => filterList(todos), [todos]) |
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps) internally. If you need a stable reference to pass around, use useCallback. If you need to cache a computed result, use useMemo.
Common mistakes
1. Empty deps when the callback captures state or props
// Wrong: user.id is always the mount-time value
const save = useCallback(() => api.save(user.id), []);
// Fix
const save = useCallback(() => api.save(user.id), [user.id]);With [], the closure captures the initial user.id and ignores all updates. Classic stale closure bug.
2. Wrapping functions that aren't passed as props
// Wrong: no memoized child receives this, zero benefit
const formatDate = useCallback((d) => d.toISOString(), []);
// Fix: just a regular function
const formatDate = (d) => d.toISOString();You pay the comparison cost on every render and gain nothing.
3. Object or array literal in the deps array
// Wrong: config is a new object every render -> tick always recreates
const config = { delay };
const tick = useCallback(() => doSomething(config), [config]);
// Fix: use the primitive directly
const tick = useCallback(() => doSomething(delay), [delay]);Object.is compares references, not content. A new object literal on every render always fails the comparison and defeats the memoization.
4. Disabling react-hooks/exhaustive-deps
This ESLint rule catches deps you forgot to include. Disabling it to silence a warning is a pattern reviewers specifically look for in code reviews - and it ships stale closures to production.
5. Stale closure in timers
// BUG: captures delay=1000 at mount, ignores later updates
const tick = useCallback(() => {
setCount(c => c + 1);
}, []); // missing delay in deps
useEffect(() => {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [tick, delay]);Toggling delay shows the new value in the UI but the interval fires at the original speed. Fix: add delay to useCallback deps. tick will recreate when delay changes, and the interval will use the correct value.
Real-world usage
- React TodoMVC: stable
onRemovecallback passed toReact.memo(Todo)items - Redux Toolkit Query:
useLazyQuerytrigger wrapped stable for button click handlers - Material-UI DataGrid: row handlers memoized to skip re-renders across hundreds of rows
- Next.js App Router: server action callbacks kept stable across client navigations
React Compiler (shipping with React 19) auto-memoizes components and hooks, which may reduce the need for manual useCallback in projects that adopt it.
Follow-up questions
Q: What is the difference between useCallback(fn, []) and storing a function in useRef?
A: useCallback re-runs if deps change and participates in the exhaustive-deps lint rule. useRef never re-runs and has no deps check, so it fits one-time inits like storing an interval ID, but not callbacks that close over props or state.
Q: Does useCallback help if the child isn't wrapped in React.memo?
A: Not for skipping renders. But if the function is a useEffect dependency, a stable reference prevents the effect from re-running on every render.
Q: Why does React use shallow comparison for deps instead of deep equality?
A: Deep comparison on large arrays or nested objects would cost more than the re-render it tries to prevent. Keep deps as primitives or stable references and push stability upstream.
Q: Can useCallback hurt performance?
A: Yes. With 1000 memoized items and no dep changes, the memoization overhead is measurable in React Profiler (around 12ms in benchmarks). If deps change frequently, the hook recreates anyway and you paid the cost for nothing. Profile before adding it.
Q (senior): In RTK Query, why does wrapping a useMutation trigger in useCallback matter?
A: RTK Query caches results by argument reference. An unstable trigger creates a new cache key on every render, causing cache misses and breaking optimistic updates. A stable reference keeps the cache hit and lets the optimistic flow work correctly.
Examples
Memoized child skips re-render
import { useState, useCallback } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = useCallback((text) => {
setTodos(t => [...t, { id: Date.now(), text, done: false }]);
}, []);
const toggleTodo = useCallback((id) => {
setTodos(t => t.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}, []);
return (
<div>
<AddForm onAdd={addTodo} />
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={toggleTodo} />
))}
</div>
);
}
const TodoItem = React.memo(({ todo, onToggle }) => {
console.log(`Render item ${todo.id}`);
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</label>
);
});toggleTodo holds the same reference on every render. Adding a new todo re-renders TodoList but each existing TodoItem skips because onToggle didn't change.
Stabilizing a useEffect dependency
function UserProfile({ userId }) {
const [profile, setProfile] = useState(null);
// Without useCallback: fetchProfile is new every render
// -> useEffect fires every render -> infinite loop
const fetchProfile = useCallback(async () => {
const data = await api.getUser(userId);
setProfile(data);
}, [userId]); // re-fetches only when userId changes
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
return profile ? <div>{profile.name}</div> : <div>Loading...</div>;
}Without useCallback, a new fetchProfile on every render makes useEffect fire continuously. With it, the effect only runs when userId actually changes.
Stale closure in a timer
function Timer() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
// BUG: captures delay=1000 at mount, never updates
const tick = useCallback(() => {
setCount(c => c + 1);
}, []); // missing delay in deps
useEffect(() => {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [tick, delay]);
return (
<>
<div>Count: {count} | Delay: {delay}ms</div>
<button onClick={() => setDelay(d => d === 1000 ? 100 : 1000)}>
Toggle speed
</button>
</>
);
}Clicking "Toggle speed" updates the UI to show 100ms but the interval still fires every 1000ms. The closure captured the initial delay. Fix: add delay to useCallback deps. tick will recreate when delay changes, and the interval will use the correct value.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.