Skip to main content

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
  • useCallback caches the function and returns the cached version if deps match
  • Main use case: callbacks passed to React.memo children to skip unnecessary re-renders
  • Without a memoized child receiving it, useCallback adds overhead with no render savings
  • Rule: profile with React DevTools Profiler first, then add useCallback only where it actually helps

Quick example

jsx
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:

js
const a = () => {}; const b = () => {}; console.log(a === b); // false

React.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 useEffect or useMemo - 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

useCallbackuseMemo
ReturnsA functionA computed value
Typical useStable callback for a child or effect depResult of an expensive calculation
ExampleuseCallback(() => 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

jsx
// 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

jsx
// 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

jsx
// 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

jsx
// 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 onRemove callback passed to React.memo(Todo) items
  • Redux Toolkit Query: useLazyQuery trigger 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

jsx
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

jsx
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

jsx
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?