useLatest(value) returns a ref object whose .current always holds the value from the most recent render. The ref object itself never changes — the same box comes back on every render — so any code that grabs it once can keep reading fresh values out of it.
That matters because of stale closures. A callback created inside an effect that runs once ([] dependencies) captures the props and state of the render that created it, and keeps reading those forever, no matter how many renders follow. useLatest gives that callback a box to read instead of a value to remember.
function useLatest<T>(value: T): { readonly current: T };
The same object comes back every render; only .current changes.
A mount-only interval captures the first render's count and never lets go:
function Ticker({ count }) {
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, []); // set up once — this closure keeps render 1's count forever
}
// count goes 0, 1, 2, 3... but the interval logs 0, 0, 0, 0...
The same interval, reading the box instead:
function Ticker({ count }) {
const latestCount = useLatest(count);
useEffect(() => {
const id = setInterval(() => console.log(latestCount.current), 1000);
return () => clearInterval(id);
}, []); // still set up once — the ref is stable, so nothing needs to re-run
}
// count goes 0, 1, 2, 3... and the interval logs 0, 1, 2, 3...
.current holds the newest value. After a render with value X, .current is X — not the previous value, and not a copy of it.0, '', false, null, and undefined all have to land in .current unchanged. Don't skip the write for them.You'll build one mutable box that survives every render and repoint it at the newest value each time, so code that outlives the render — an interval, a subscription, an event handler — can read fresh data out of it.
You open a WebSocket in an effect with [] dependencies, because you only want to connect once. Inside the message handler you read roomId. Six renders later the user has switched rooms twice, and the handler is still posting to the room from the first render. It is the prompt's interval bug in different clothes. The handler isn't broken — it's a closure, a function that permanently remembers the variables that existed where it was defined, and render 1's roomId is one of those variables. Adding roomId to the dependency array would refresh it, but at the price of tearing down and reopening the socket every time the room changes. useLatest is the other way out: hand the handler something whose contents you can change.
Two facts do all the work here. First, a closure captures variables, and a function created during render 1 keeps render 1's variables for as long as it lives. Second, a ref is a single object that React hands back unchanged on every render. Put those together and the fix writes itself: don't let the callback capture the value, let it capture the box. The box never changes, so the callback holding it is never stale — and the value inside the box can be replaced as often as you like. You're giving the callback the address of a noticeboard instead of a photocopy of what was pinned to it this morning.
The hook has to hand back a box holding the newest value. So: build a box holding the newest value.
function useLatest(value) {
// A box with the current value in it — job done?
return { current: value };
}
This is right about the value and wrong about the box. The object literal is evaluated on every render, so render 1 produces one box, render 2 a second, render 3 a third. Your [] effect grabbed the render-1 box and is still holding it, while the newest value goes into a box that effect has never seen. You've reproduced the exact bug you set out to fix, one level down — the callback isn't holding a stale value any more, it's holding a stale box.
const { useRef } = require('react');
function useLatest(value) {
// useRef gives you ONE object for the whole life of the component. React
// reads this argument on the first render only — every render after that
// hands the same object back and ignores it. That is what keeps the identity
// stable, and the stable identity is what the callback is holding.
const ref = useRef(value);
// Repoint the box at the newest value on every render. Unconditional on
// purpose: a `if (value)` guard would silently skip 0, '', false and null,
// which are values like any other.
ref.current = value;
return ref;
}
module.exports = { useLatest };
Two things changed. The box now comes from useRef, so there is exactly one of it forever — the callback that grabbed it on render 1 is holding the same object that render 50 writes into. And the update is an assignment into that object rather than a fresh literal, so .current moves while the box itself doesn't.
That assignment runs during render, which is worth being upfront about: React's docs tell you not to write refs while rendering. This is the one place the ecosystem does it anyway — react-use and ahooks both ship these three lines — and it buys something real. .current is fresh before this render's effects run, so an effect in the same commit reads the new value rather than the old one. The cost is that React can start a render and then throw it away (a child suspends, a higher-priority update arrives); your write already happened, so the box can briefly describe a render that never reached the screen. Since nothing reads the box until later — from a timer, a handler, an effect — that window closes before anyone looks. If you'd rather not take that trade, the useLayoutEffect variant in Going further is the conservative version.
Take the Ticker from the prompt, with count going 0, 1, 2 across three renders.
count is 0. useRef(0) creates the box with 0 in it. ref.current = 0 writes the same value again — a no-op this once, but the line has to be there for what comes next. The hook returns the box. After the render commits, the effect runs: it starts the interval, and the callback it creates captures latestCount — the box, not 0.count is 1. useRef(0) does not build a second box, and does not even look at that 0 — it returns the box from render 1. Then ref.current = 1 replaces the contents. The effect does not re-run, because its dependency array is empty, so the interval is still the same callback holding the same box.count is 2. Same box again; ref.current = 2.latestCount.current and gets 2. Nothing about the callback changed between renders 1 and 3 — it was never re-created and never re-subscribed. The only thing that moved was the contents of the box it reads.return { current: value } is fresh but not stable, so the effect that grabbed it on render 1 keeps reading render 1's value. Fix: useRef, so there is only ever one box.useRef(value) to follow value. const ref = useRef(value); return ref; looks like it tracks the argument, but React reads that argument on the first render only — the box freezes at the first value and stays there for the life of the component. Fix: the ref.current = value line on every render is the part that does the tracking.if (value) ref.current = value keeps the box on its old value whenever the new one is 0, '', false, or null — a user clearing a search box sends '' and the box lies about it. Fix: assign unconditionally.value in scope; ref.current is a longer way to spell it. The box only earns its keep in code that outlives the render — intervals, subscriptions, listeners, useCallback([]) bodies.useState — useLatest is a read-side box for code React isn't re-running.useLayoutEffect variant. const ref = useRef(value); useLayoutEffect(() => { ref.current = value; }); return ref; keeps render pure by deferring the write until after the commit. Layout effects run before any passive effect in the same commit, so effects still read fresh values; what you give up is that during render — and inside any layout effect that runs earlier — .current is one render behind. It's the better default when the ref is only ever read from event handlers.useEventCallback / useEvent. useEventCallback is this hook with a wrapper on top: keep the latest function in the box, then return one useCallback([]) wrapper that looks up ref.current at call time. The result is a callback whose identity never changes — safe to hand to a React.memo child or an effect's dependency array — and that never goes stale. React's useEvent proposal is the same idea, built in.{ readonly current: T } rather than a full MutableRefObject<T>. Consumers are meant to read the box, not write to it — the hook owns what's inside — and the type says so out loud.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useLatest(value) returns a ref object whose .current always holds the value from the most recent render. The ref object itself never changes — the same box comes back on every render — so any code that grabs it once can keep reading fresh values out of it.
That matters because of stale closures. A callback created inside an effect that runs once ([] dependencies) captures the props and state of the render that created it, and keeps reading those forever, no matter how many renders follow. useLatest gives that callback a box to read instead of a value to remember.
function useLatest<T>(value: T): { readonly current: T };
The same object comes back every render; only .current changes.
A mount-only interval captures the first render's count and never lets go:
function Ticker({ count }) {
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, []); // set up once — this closure keeps render 1's count forever
}
// count goes 0, 1, 2, 3... but the interval logs 0, 0, 0, 0...
The same interval, reading the box instead:
function Ticker({ count }) {
const latestCount = useLatest(count);
useEffect(() => {
const id = setInterval(() => console.log(latestCount.current), 1000);
return () => clearInterval(id);
}, []); // still set up once — the ref is stable, so nothing needs to re-run
}
// count goes 0, 1, 2, 3... and the interval logs 0, 1, 2, 3...
.current holds the newest value. After a render with value X, .current is X — not the previous value, and not a copy of it.0, '', false, null, and undefined all have to land in .current unchanged. Don't skip the write for them.You'll build one mutable box that survives every render and repoint it at the newest value each time, so code that outlives the render — an interval, a subscription, an event handler — can read fresh data out of it.
You open a WebSocket in an effect with [] dependencies, because you only want to connect once. Inside the message handler you read roomId. Six renders later the user has switched rooms twice, and the handler is still posting to the room from the first render. It is the prompt's interval bug in different clothes. The handler isn't broken — it's a closure, a function that permanently remembers the variables that existed where it was defined, and render 1's roomId is one of those variables. Adding roomId to the dependency array would refresh it, but at the price of tearing down and reopening the socket every time the room changes. useLatest is the other way out: hand the handler something whose contents you can change.
Two facts do all the work here. First, a closure captures variables, and a function created during render 1 keeps render 1's variables for as long as it lives. Second, a ref is a single object that React hands back unchanged on every render. Put those together and the fix writes itself: don't let the callback capture the value, let it capture the box. The box never changes, so the callback holding it is never stale — and the value inside the box can be replaced as often as you like. You're giving the callback the address of a noticeboard instead of a photocopy of what was pinned to it this morning.
The hook has to hand back a box holding the newest value. So: build a box holding the newest value.
function useLatest(value) {
// A box with the current value in it — job done?
return { current: value };
}
This is right about the value and wrong about the box. The object literal is evaluated on every render, so render 1 produces one box, render 2 a second, render 3 a third. Your [] effect grabbed the render-1 box and is still holding it, while the newest value goes into a box that effect has never seen. You've reproduced the exact bug you set out to fix, one level down — the callback isn't holding a stale value any more, it's holding a stale box.
const { useRef } = require('react');
function useLatest(value) {
// useRef gives you ONE object for the whole life of the component. React
// reads this argument on the first render only — every render after that
// hands the same object back and ignores it. That is what keeps the identity
// stable, and the stable identity is what the callback is holding.
const ref = useRef(value);
// Repoint the box at the newest value on every render. Unconditional on
// purpose: a `if (value)` guard would silently skip 0, '', false and null,
// which are values like any other.
ref.current = value;
return ref;
}
module.exports = { useLatest };
Two things changed. The box now comes from useRef, so there is exactly one of it forever — the callback that grabbed it on render 1 is holding the same object that render 50 writes into. And the update is an assignment into that object rather than a fresh literal, so .current moves while the box itself doesn't.
That assignment runs during render, which is worth being upfront about: React's docs tell you not to write refs while rendering. This is the one place the ecosystem does it anyway — react-use and ahooks both ship these three lines — and it buys something real. .current is fresh before this render's effects run, so an effect in the same commit reads the new value rather than the old one. The cost is that React can start a render and then throw it away (a child suspends, a higher-priority update arrives); your write already happened, so the box can briefly describe a render that never reached the screen. Since nothing reads the box until later — from a timer, a handler, an effect — that window closes before anyone looks. If you'd rather not take that trade, the useLayoutEffect variant in Going further is the conservative version.
Take the Ticker from the prompt, with count going 0, 1, 2 across three renders.
count is 0. useRef(0) creates the box with 0 in it. ref.current = 0 writes the same value again — a no-op this once, but the line has to be there for what comes next. The hook returns the box. After the render commits, the effect runs: it starts the interval, and the callback it creates captures latestCount — the box, not 0.count is 1. useRef(0) does not build a second box, and does not even look at that 0 — it returns the box from render 1. Then ref.current = 1 replaces the contents. The effect does not re-run, because its dependency array is empty, so the interval is still the same callback holding the same box.count is 2. Same box again; ref.current = 2.latestCount.current and gets 2. Nothing about the callback changed between renders 1 and 3 — it was never re-created and never re-subscribed. The only thing that moved was the contents of the box it reads.return { current: value } is fresh but not stable, so the effect that grabbed it on render 1 keeps reading render 1's value. Fix: useRef, so there is only ever one box.useRef(value) to follow value. const ref = useRef(value); return ref; looks like it tracks the argument, but React reads that argument on the first render only — the box freezes at the first value and stays there for the life of the component. Fix: the ref.current = value line on every render is the part that does the tracking.if (value) ref.current = value keeps the box on its old value whenever the new one is 0, '', false, or null — a user clearing a search box sends '' and the box lies about it. Fix: assign unconditionally.value in scope; ref.current is a longer way to spell it. The box only earns its keep in code that outlives the render — intervals, subscriptions, listeners, useCallback([]) bodies.useState — useLatest is a read-side box for code React isn't re-running.useLayoutEffect variant. const ref = useRef(value); useLayoutEffect(() => { ref.current = value; }); return ref; keeps render pure by deferring the write until after the commit. Layout effects run before any passive effect in the same commit, so effects still read fresh values; what you give up is that during render — and inside any layout effect that runs earlier — .current is one render behind. It's the better default when the ref is only ever read from event handlers.useEventCallback / useEvent. useEventCallback is this hook with a wrapper on top: keep the latest function in the box, then return one useCallback([]) wrapper that looks up ref.current at call time. The result is a callback whose identity never changes — safe to hand to a React.memo child or an effect's dependency array — and that never goes stale. React's useEvent proposal is the same idea, built in.{ readonly current: T } rather than a full MutableRefObject<T>. Consumers are meant to read the box, not write to it — the hook owns what's inside — and the type says so out loud.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.