“Why does my useCallback always log the initial value?” Or, from the other direction: “what does useCallback actually do, and when would you not use it?” Both questions probe the same idea. A memoised callback is not just a stable function; it is a stable closure, and that closure keeps whatever values it captured on the render that created it.
Interviewers use this to separate candidates who sprinkle useCallback for performance from those who can predict exactly which state a callback will read and why.
Before you start
You should understand closures, the state snapshot model (each render has its own constant values), and the idea of reference equality. Knowing React.memo helps for the performance half. Applies to React 18 and 19; with the React Compiler enabled, much manual useCallback becomes unnecessary, but the stale-closure reasoning still matters for code that is not compiled and for interviews.
The short answer
useCallback(fn, deps) returns the same function object across renders until one of the dependencies changes by Object.is. Because it returns the old function, it also returns that function’s old closure, so any prop or state the callback reads must be in the dependency array or it will see stale values. To keep a callback stable while still using the latest state, use a functional updater so the state is not read directly. Use useCallback only when identity matters: a memoised child, a dependency of another hook, or a custom hook’s returned API.
How it works
Conceptually, React caches the function along with its dependencies:
function useCallbackModel(cache, fn, deps) {
if (cache.deps && deps.every((d, i) => Object.is(d, cache.deps[i]))) {
return cache.fn; // old function, old closure
}
cache.fn = fn;
cache.deps = deps;
return fn;
}On every render you create a new arrow function, but if the dependencies match, React hands back the cached one and your new function is discarded. That is the source of stale values:
function Form() {
const [text, setText] = useState('');
const submit = useCallback(() => {
sendToServer(text); // captured on the first render: ''
}, []);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SubmitButton onSubmit={submit} />
</>
);
}submit was created on render one, when text was the empty string. The empty array means React never replaces it, so it sends an empty string forever.
Step-by-step walkthrough
Step 1: Include everything the callback reads
The correct version lists text:
const submit = useCallback(() => {
sendToServer(text);
}, [text]);The callback is now recreated whenever text changes. That is correct and often fine. The react-hooks/exhaustive-deps rule flags the original mistake automatically, which is why it should stay enabled.
Step 2: Remove state reads with functional updates
When the callback only needs state to compute the next state, it does not need to read it at all:
const addItem = useCallback(item => {
setItems(prev => [...prev, item]);
}, []);setItems is stable and prev is supplied by React at update time, so the empty array is genuinely correct. The callback stays stable across every render and never goes stale.
Step 3: Check whether identity matters at all
useCallback helps only when something compares the function:
const Row = memo(function Row({ item, onRemove }) { /* ... */ });
function List({ items, setItems }) {
const onRemove = useCallback(
id => setItems(prev => prev.filter(i => i.id !== id)),
[setItems]
);
return items.map(i => <Row key={i.id} item={i} onRemove={onRemove} />);
}Without useCallback, onRemove is a new function every render, and memo on Row would never skip. If Row were not memoised, useCallback would add cost without benefit. See memo and reference stability.
Step 4: Handle callbacks that need fresh values but stable identity
Sometimes you need both: a handler passed to a third-party widget that should not be re-registered, but that reads the latest props. React 19.2 adds useEffectEvent for functions called from effects. For handlers outside effects, the established pattern is a ref holding the latest function:
const latest = useRef(onSave);
useLayoutEffect(() => { latest.current = onSave; });
const stableSave = useCallback((...args) => latest.current(...args), []);Use this deliberately, because a function that reads the ref during render would observe inconsistent values.
Worked scenario
A chat input auto-saves drafts on a debounce:
function Composer({ channelId }) {
const [draft, setDraft] = useState('');
const save = useCallback(() => {
saveDraft(channelId, draft);
}, []); // "so the debounce is not reset"
const debouncedSave = useMemo(() => debounce(save, 500), [save]);
return <textarea value={draft} onChange={e => { setDraft(e.target.value); debouncedSave(); }} />;
}Users find their drafts saved as empty strings, and drafts from one channel appearing in another. The callback captured channelId and draft from the first render.
Adding the dependencies makes save change on every keystroke, which recreates the debounced function and defeats the debounce. The fix passes the value at call time instead of reading it from the closure:
const debouncedSave = useMemo(
() => debounce((id, text) => saveDraft(id, text), 500),
[]
);
function onChange(e) {
setDraft(e.target.value);
debouncedSave(channelId, e.target.value);
}The debounced function is stable and takes explicit arguments, so it can never read a stale draft or channel. A full implementation would also cancel the pending call when channelId changes or the component unmounts.
Common mistake
- “useCallback makes my component faster.” It makes a function’s identity stable. Speed only improves if a memoised child or a hook dependency relies on that identity.
- Empty arrays to “avoid re-creating the function”. Re-creating a function is cheap. Reading stale state is a bug.
- Confusing it with
useMemo.useCallback(fn, deps)is equivalent touseMemo(() => fn, deps); it memoises the function, it does not call it. - Believing the latest state is always read because the component re-rendered. The re-render created a new function, but
useCallbackthrew it away and returned the old one.
Verify the behavior
Log the captured value and a render counter:
const [n, setN] = useState(0);
const stale = useCallback(() => console.log('stale sees', n), []);
const fresh = useCallback(() => console.log('fresh sees', n), [n]);
return (
<>
<button onClick={() => setN(x => x + 1)}>n = {n}</button>
<button onClick={() => { stale(); fresh(); }}>log</button>
</>
);Click the first button three times, then “log”. The console shows stale sees 0 and fresh sees 3. To prove identity, store the previous function in a ref and log Object.is(prevRef.current, stale); it stays true across renders.
Follow-up questions
Is the state setter safe to omit? Yes. React guarantees setters from useState and dispatch from useReducer are stable, so the linter allows leaving them out.
What about callbacks passed to custom hooks? If the hook puts the callback in an effect’s dependency array, an unstable callback re-runs that effect every render. Document whether your hook expects a stable function. See custom hook contracts.
Does the React Compiler replace useCallback? For components it compiles, it memoises functions and values automatically based on what they read, so manual useCallback becomes mostly redundant. Understanding captured values still matters for debugging.
Can useCallback cause an infinite loop? Not by itself, but a callback in an effect’s dependency array that is recreated every render, combined with the effect setting state, can loop. See maximum update depth exceeded.
Interview exercise
The counter below increments only to 1 no matter how many seconds pass. Explain and fix it in two different ways.
const [count, setCount] = useState(0);
const tick = useCallback(() => setCount(count + 1), []);
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [tick]);Answer and reasoning
tick captured count equal to 0 and is never replaced, so every second it calls setCount(1). Fix one: use a functional update, useCallback(() => setCount(c => c + 1), []). The callback stays stable, the interval is created once, and each tick increments correctly. Fix two: add count to the dependencies. That works but recreates tick every second, which tears down and recreates the interval each tick; it is correct but wasteful and can drift. The functional updater is the better answer because the callback only needs the previous value to compute the next one.
Continue learning
Explore the React interview questions and the React MCQs. Related notes: memo and reference stability, memo, useMemo and useCallback and effect dependencies. Official reference: the useCallback reference on react.dev.