useLatest Ref: The Anti-Stale-Closure Pattern
The five-line hook I reach for whenever an effect, a setTimeout, or an external subscription needs to call back into the latest value of a prop or state without re-binding.
By @petrawilson
December 24, 2025
·
Updated August 11, 2026
852 views
20
4.4 (8)
// useLatest: a one-line hook that holds a ref pointing at the most recent value.
// Solves the stale-closure bug where setTimeout / subscriptions see a prop from
// the render in which they were registered, not the latest one.
const { useRef } = (typeof React !== 'undefined' ? React : {
useRef: (init) => ({ current: init }),
});
function useLatest(value) {
const ref = useRef(value);
ref.current = value; // assign on every render, no useEffect needed
return ref;
}
// Demo: simulate three renders with different `count` props, holding the same
// ref across renders the way React's reconciler does. The stored callback
// captures `count` directly OR reads from the ref, and we compare what each sees.
const countRef = { current: undefined }; // persistent across simulated renders
let storedStale = null;
let storedFresh = null;
function simulateRender(count) {
// Hook body, equivalent to: const countRef = useLatest(count);
countRef.current = count;
// Register handlers only on the first render, then never again.
if (!storedStale) storedStale = () => console.log('stale closure sees count =', count);
if (!storedFresh) storedFresh = () => console.log('via useLatest sees count =', countRef.current);
}
simulateRender(0);
simulateRender(1);
simulateRender(2);
storedStale(); // sees 0 (the render in which it was bound)
storedFresh(); // sees 2 (the latest render's value)useLatest is the smallest custom hook I keep around: three lines of body, one assignment per render. The bug it solves is the most common cause of "why is my React app stale": a setTimeout or websocket handler is registered once, and the function it calls captured count from the render that registered it, not the current one. Running the assignment outside useEffect is intentional: effects fire after commit, but the render itself is what the next callback should see. The shim above does not re-render across calls, so accordion 1 fakes two renders to print the contrast: stale closure sees 0, useLatest sees the most recent value.
// Real use case: an interval that should always run with the latest `delay`
// or `onTick` callback, without resetting the timer on every render.
const { useState, useRef, useEffect, useCallback } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useRef: (init) => ({ current: init }),
useEffect: () => {},
useCallback: (f) => f,
});
function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useFreshInterval(callback, delay) {
const callbackRef = useLatest(callback);
useEffect(() => {
if (delay == null) return undefined;
const id = setInterval(() => callbackRef.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Drive the pattern outside React: pretend three renders happen, each with a
// fresh callback that would otherwise need a re-binding effect.
let ticks = 0;
let currentCb = () => console.log('cb v0, ticks=', ticks);
const cbRef = { current: currentCb };
const id = setInterval(() => cbRef.current(), 5);
setTimeout(() => {
cbRef.current = () => { ticks++; console.log('cb v1, ticks=', ticks); };
}, 12);
setTimeout(() => {
cbRef.current = () => { ticks++; console.log('cb v2 (final), ticks=', ticks); };
}, 22);
setTimeout(() => {
clearInterval(id);
console.log('done; final tick count:', ticks);
}, 35);This is the production pattern that justifies useLatest. A setInterval that should always call the latest onTick cannot include onTick in its dep array (the timer would reset every render, drifting the cadence). Stashing the callback in a ref and reading callbackRef.current() from inside the interval gives you both: a stable timer and an always-fresh handler. Outside React I cannot drive a real interval through three component renders, so the demo manually swaps cbRef.current over time to simulate the same effect. In a real component I have used this for charts, websocket sends, and analytics throttles; in each case the alternative was a re-binding useEffect that broke the timing.
