useGetState(initialState) is useState with a third return value: a getter that reads the newest state at the moment you call it. The state and the setter behave exactly as they always did — getState() is the addition.
It exists for callbacks that outlive the render that created them: an interval, a socket handler, an event listener registered once. Such a callback keeps reading its own render's state forever. Functional updates already fix the write side of that — setState(n => n + 1) is handed the freshest state, so a mount-only interval can count correctly without ever looking at state. Nothing fixes the read side. The moment the callback has to branch on the current state, log it, or send it somewhere, it has to actually see the value.
function useGetState<S>(
initialState: S | (() => S),
): [S, (update: S | ((prev: S) => S)) => void, () => S];
getState must be the same function on every render, so it is safe inside a mount-only effect or a useCallback with an empty dependency array.
A mount-only interval that has to decide whether to act:
function Uploader() {
const [queue, setQueue, getQueue] = useGetState([]);
useEffect(() => {
const id = setInterval(() => {
const pending = getQueue(); // the queue as it is NOW
if (pending.length === 0) return; // nothing to send
send(pending);
setQueue([]);
}, 1000);
return () => clearInterval(id);
}, []); // set up once — getQueue is stable, so nothing has to re-run
}
Reading queue directly there gives you [] on every tick forever, and the uploader never sends anything.
The state and the setter are unchanged from useState:
const [count, setCount, getCount] = useGetState(0);
setCount(3); // count is 3 on the next render
setCount((n) => n + 1); // updaters work — this is useState's own setter
getCount(); // 4, without a rerender to read it back
getState identity never changes. Callers hold onto it inside long-lived callbacks; handing back a new function each render leaves them calling the old one, which is the bug this hook exists to fix.useState's contract. Functional updates and a lazy initial state (useGetState(() => expensive())) both work — they are React's features, not yours, so pass the setter straight through rather than wrapping it.getState() is current as of the last render. It reads state, not the setter's queue: calling setCount(1) and then getCount() on the very next line still reports the old count.0, '', false and null all have to come back out of the getter unchanged.You'll bolt a read-side escape hatch onto useState: one mutable box that mirrors the state on every render, and one function — the same function forever — that reads out of it whenever it's called.
Your component keeps a queue of files to upload, and an interval flushes it every second. You start the interval in an effect with [] dependencies, because you want one interval, not a new one per render. Inside the tick you check queue.length to decide whether there's anything to send. It sends nothing, ever. The tick is a closure — a function that permanently remembers the variables that existed where it was defined — and it was defined on render 1, when the queue was empty. Twenty renders later it is still looking at render 1's empty array.
Here's the part worth slowing down for, because the first fix everyone reaches for is the right fix to a different problem. setQueue(prev => [...prev, file]) does work from inside that stale tick: React hands your updater function the freshest state, so the write lands correctly no matter how old the closure around it is. Writes have a built-in escape hatch. Reads don't. The moment the callback needs to decide — is the queue non-empty, has the user stopped typing, is this still the room we're in — it needs to see the value, and there is no updater to hand it over. Reading inside the updater is not the loophole it looks like: updaters have to stay pure, and React calls them twice in development to make sure you find out. That gap is the entire hook — getState() is the read-side escape hatch useState never shipped.
The hook has to hand back a function that returns the newest state. So: hand back a function that returns the state.
const { useState } = require('react');
function useGetState(initialState) {
const [state, setState] = useState(initialState);
// A getter that returns the state — job done?
const getState = () => state;
return [state, setState, getState];
}
Try this from the component body and it looks perfect, because there you always hold the getter the current render just built. But the component body already has state in scope — it was never the caller with the problem. The caller with the problem is the interval, and the interval grabbed the getter that render 1 built. That arrow function is a closure over render 1's state, so calling it returns 0 no matter how much later you call it. You haven't removed the staleness; you've wrapped it in a function and handed it over. Now the callback holds a stale getter instead of a stale value.
const { useState, useRef, useCallback } = require('react');
function useGetState(initialState) {
// Plain useState, untouched. The setter you hand back is React's own, so
// functional updates and a lazy initial state come along for free — they are
// useState's features, and wrapping the setter would only put them at risk.
const [state, setState] = useState(initialState);
// ONE box for the life of the component. useRef reads this argument on the
// first render only; every render after hands the same object back and
// ignores it. That never-changing identity is what the getter closes over.
const stateRef = useRef(state);
// Repoint the box at the newest state on every render. Unconditional on
// purpose: an `if (state)` guard would silently skip 0, '', false and null,
// which are states like any other.
stateRef.current = state;
// One function, built on the first render and reused forever. The empty
// dependency array is doing the real work here — it is what lets a
// mount-only effect grab this getter once and still read fresh state from
// it. Note what the body closes over: `stateRef`, the box that never
// changes, and NOT `state`, the value that changes every render.
const getState = useCallback(() => stateRef.current, []);
return [state, setState, getState];
}
module.exports = { useGetState };
Two things changed, and they only work as a pair. The value moved into a ref — a single object React hands back unchanged on every render, whose .current you can rewrite without scheduling one — so there is exactly one box. And the getter moved into a useCallback with an empty dependency array, so there is exactly one getter: the one the interval grabbed on render 1 is the one that renders 2 through 50 keep handing back. A stable getter over a changing value would be useless, and a fresh getter over a stable box would be the first attempt again.
That stateRef.current = state line runs during render, which React's docs advise against. This hook is useLatest fused to useState, and the trade-off is exactly the one worked through there — it applies here unchanged, and the same useLayoutEffect alternative is available if you'd rather not take it. ahooks ships this exact shape.
The honest first question is not "how do I read fresh state in this callback?" It's "why is this callback still alive from render 1?" Very often the answer is that it doesn't need to be. Put queue in the effect's dependency array, let the effect tear down and re-create the interval whenever the queue changes, and every closure is fresh — no hook, no ref, no getter. If that's an option, take it. Re-creating a callback is cheap and the code that results is the code React wants you to write.
getState earns its place when re-creating the callback is the expensive part, not the cheap part:
roomId means every state change drops the socket and reconnects. The connection is the cost; the closure was never the problem.keydown handler on window that has to know the current mode. Add/remove on every render is churn you can see in a profiler.The pattern in all three: the callback's lifetime is deliberate. When that's true, reading through a getter is honest. When it isn't, you're routing around a dependency array you should have just filled in.
Take the Uploader from the prompt, with the queue going [], then ['a.png'], then ['a.png', 'b.png'].
queue is []. useState sets the state up. useRef(state) creates the box with that same empty array in it, and stateRef.current = state writes it again — a no-op this once, but the line has to be there for what comes next. useCallback(..., []) builds getQueue and React memoizes it. After the render commits, the effect runs: it starts the interval, and the tick it creates captures getQueue and setQueue. Notice what it did not capture: a value.a.png. setQueue(['a.png']) schedules a render. Render 2: useRef does not build a second box — it hands back render 1's box and doesn't even look at its argument. stateRef.current becomes ['a.png']. useCallback sees the same empty dependency array and hands back the same getQueue — the one the interval is holding. The effect doesn't re-run, because its dependency array is empty, so the interval is still the same tick.b.png added. Same box, same getter; stateRef.current is now ['a.png', 'b.png'].getQueue(). That runs the function built on render 1 — but the only thing that function closed over is stateRef, so it reads .current right now and returns ['a.png', 'b.png']. pending.length is 2, the branch is taken, both files go out, and setQueue([]) empties the queue. Nothing about the interval changed across those three renders: never re-created, never re-scheduled.[]. getQueue() returns [], pending.length is 0, and the tick returns early. That early return is the thing no functional updater could have done for it — setQueue(prev => ...) is handed the fresh queue, but its job is to produce the next state, not to decide whether to make a network call.const getState = () => state is fresh every render and welded to that render's state, so the mount-only effect that grabbed render 1's copy calls it forever. Fix: useCallback(() => stateRef.current, []) — one function, closing over the box instead of the value.setState(n => { send(n); return n; }) looks like a free way to see the newest state, and it is a real bug: React expects updaters to be pure and deliberately calls them twice in development Strict Mode so you notice. You send twice. Fix: getState() is the read; leave the updater to compute the next state and nothing else.getState() to be current the instant you call the setter. setCount(1); getCount(); // still 0. The box is rewritten during the next render, not by the setter, so within one synchronous block the getter still reports the old state. Fix: if you need the value you just computed, you already have it — the getter is for reads that happen after React has caught up, which is every read from a timer, a handler, or an await.if (state) stateRef.current = state keeps the box on its old state whenever the new one is 0, '', false or null — a counter reset to 0 would still read as the old count. Fix: assign unconditionally.getState() in the component body. During render you already have state in scope, and getState() is a longer way to spell it. The getter only earns its keep in code React isn't re-running: intervals, subscriptions, listeners, useCallback([]) bodies.useRef instead of useCallback for the getter. const getState = useRef(() => stateRef.current).current; is the paranoid version. React's docs are explicit that memoization is a performance hint, not a semantic guarantee — a future version may throw a useCallback cache away — and a getter whose stable identity you promised in the signature is where that would hurt. Nothing does today; useRef makes the guarantee yours rather than React's.useGetSet. react-use takes the other fork — keep the state in a ref, return only a getter and a setter, and have the setter force its own rerender. Nothing can go stale because there is no state value to capture, but every read in your JSX becomes get().useGetState(() => myHandler) returns myHandler as the state, not the arrow function, because useState reads a function argument as a lazy initializer. You inherit it the moment you pass the setter through untouched — the right call, but worth knowing when the state is itself a function.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useGetState(initialState) is useState with a third return value: a getter that reads the newest state at the moment you call it. The state and the setter behave exactly as they always did — getState() is the addition.
It exists for callbacks that outlive the render that created them: an interval, a socket handler, an event listener registered once. Such a callback keeps reading its own render's state forever. Functional updates already fix the write side of that — setState(n => n + 1) is handed the freshest state, so a mount-only interval can count correctly without ever looking at state. Nothing fixes the read side. The moment the callback has to branch on the current state, log it, or send it somewhere, it has to actually see the value.
function useGetState<S>(
initialState: S | (() => S),
): [S, (update: S | ((prev: S) => S)) => void, () => S];
getState must be the same function on every render, so it is safe inside a mount-only effect or a useCallback with an empty dependency array.
A mount-only interval that has to decide whether to act:
function Uploader() {
const [queue, setQueue, getQueue] = useGetState([]);
useEffect(() => {
const id = setInterval(() => {
const pending = getQueue(); // the queue as it is NOW
if (pending.length === 0) return; // nothing to send
send(pending);
setQueue([]);
}, 1000);
return () => clearInterval(id);
}, []); // set up once — getQueue is stable, so nothing has to re-run
}
Reading queue directly there gives you [] on every tick forever, and the uploader never sends anything.
The state and the setter are unchanged from useState:
const [count, setCount, getCount] = useGetState(0);
setCount(3); // count is 3 on the next render
setCount((n) => n + 1); // updaters work — this is useState's own setter
getCount(); // 4, without a rerender to read it back
getState identity never changes. Callers hold onto it inside long-lived callbacks; handing back a new function each render leaves them calling the old one, which is the bug this hook exists to fix.useState's contract. Functional updates and a lazy initial state (useGetState(() => expensive())) both work — they are React's features, not yours, so pass the setter straight through rather than wrapping it.getState() is current as of the last render. It reads state, not the setter's queue: calling setCount(1) and then getCount() on the very next line still reports the old count.0, '', false and null all have to come back out of the getter unchanged.You'll bolt a read-side escape hatch onto useState: one mutable box that mirrors the state on every render, and one function — the same function forever — that reads out of it whenever it's called.
Your component keeps a queue of files to upload, and an interval flushes it every second. You start the interval in an effect with [] dependencies, because you want one interval, not a new one per render. Inside the tick you check queue.length to decide whether there's anything to send. It sends nothing, ever. The tick is a closure — a function that permanently remembers the variables that existed where it was defined — and it was defined on render 1, when the queue was empty. Twenty renders later it is still looking at render 1's empty array.
Here's the part worth slowing down for, because the first fix everyone reaches for is the right fix to a different problem. setQueue(prev => [...prev, file]) does work from inside that stale tick: React hands your updater function the freshest state, so the write lands correctly no matter how old the closure around it is. Writes have a built-in escape hatch. Reads don't. The moment the callback needs to decide — is the queue non-empty, has the user stopped typing, is this still the room we're in — it needs to see the value, and there is no updater to hand it over. Reading inside the updater is not the loophole it looks like: updaters have to stay pure, and React calls them twice in development to make sure you find out. That gap is the entire hook — getState() is the read-side escape hatch useState never shipped.
The hook has to hand back a function that returns the newest state. So: hand back a function that returns the state.
const { useState } = require('react');
function useGetState(initialState) {
const [state, setState] = useState(initialState);
// A getter that returns the state — job done?
const getState = () => state;
return [state, setState, getState];
}
Try this from the component body and it looks perfect, because there you always hold the getter the current render just built. But the component body already has state in scope — it was never the caller with the problem. The caller with the problem is the interval, and the interval grabbed the getter that render 1 built. That arrow function is a closure over render 1's state, so calling it returns 0 no matter how much later you call it. You haven't removed the staleness; you've wrapped it in a function and handed it over. Now the callback holds a stale getter instead of a stale value.
const { useState, useRef, useCallback } = require('react');
function useGetState(initialState) {
// Plain useState, untouched. The setter you hand back is React's own, so
// functional updates and a lazy initial state come along for free — they are
// useState's features, and wrapping the setter would only put them at risk.
const [state, setState] = useState(initialState);
// ONE box for the life of the component. useRef reads this argument on the
// first render only; every render after hands the same object back and
// ignores it. That never-changing identity is what the getter closes over.
const stateRef = useRef(state);
// Repoint the box at the newest state on every render. Unconditional on
// purpose: an `if (state)` guard would silently skip 0, '', false and null,
// which are states like any other.
stateRef.current = state;
// One function, built on the first render and reused forever. The empty
// dependency array is doing the real work here — it is what lets a
// mount-only effect grab this getter once and still read fresh state from
// it. Note what the body closes over: `stateRef`, the box that never
// changes, and NOT `state`, the value that changes every render.
const getState = useCallback(() => stateRef.current, []);
return [state, setState, getState];
}
module.exports = { useGetState };
Two things changed, and they only work as a pair. The value moved into a ref — a single object React hands back unchanged on every render, whose .current you can rewrite without scheduling one — so there is exactly one box. And the getter moved into a useCallback with an empty dependency array, so there is exactly one getter: the one the interval grabbed on render 1 is the one that renders 2 through 50 keep handing back. A stable getter over a changing value would be useless, and a fresh getter over a stable box would be the first attempt again.
That stateRef.current = state line runs during render, which React's docs advise against. This hook is useLatest fused to useState, and the trade-off is exactly the one worked through there — it applies here unchanged, and the same useLayoutEffect alternative is available if you'd rather not take it. ahooks ships this exact shape.
The honest first question is not "how do I read fresh state in this callback?" It's "why is this callback still alive from render 1?" Very often the answer is that it doesn't need to be. Put queue in the effect's dependency array, let the effect tear down and re-create the interval whenever the queue changes, and every closure is fresh — no hook, no ref, no getter. If that's an option, take it. Re-creating a callback is cheap and the code that results is the code React wants you to write.
getState earns its place when re-creating the callback is the expensive part, not the cheap part:
roomId means every state change drops the socket and reconnects. The connection is the cost; the closure was never the problem.keydown handler on window that has to know the current mode. Add/remove on every render is churn you can see in a profiler.The pattern in all three: the callback's lifetime is deliberate. When that's true, reading through a getter is honest. When it isn't, you're routing around a dependency array you should have just filled in.
Take the Uploader from the prompt, with the queue going [], then ['a.png'], then ['a.png', 'b.png'].
queue is []. useState sets the state up. useRef(state) creates the box with that same empty array in it, and stateRef.current = state writes it again — a no-op this once, but the line has to be there for what comes next. useCallback(..., []) builds getQueue and React memoizes it. After the render commits, the effect runs: it starts the interval, and the tick it creates captures getQueue and setQueue. Notice what it did not capture: a value.a.png. setQueue(['a.png']) schedules a render. Render 2: useRef does not build a second box — it hands back render 1's box and doesn't even look at its argument. stateRef.current becomes ['a.png']. useCallback sees the same empty dependency array and hands back the same getQueue — the one the interval is holding. The effect doesn't re-run, because its dependency array is empty, so the interval is still the same tick.b.png added. Same box, same getter; stateRef.current is now ['a.png', 'b.png'].getQueue(). That runs the function built on render 1 — but the only thing that function closed over is stateRef, so it reads .current right now and returns ['a.png', 'b.png']. pending.length is 2, the branch is taken, both files go out, and setQueue([]) empties the queue. Nothing about the interval changed across those three renders: never re-created, never re-scheduled.[]. getQueue() returns [], pending.length is 0, and the tick returns early. That early return is the thing no functional updater could have done for it — setQueue(prev => ...) is handed the fresh queue, but its job is to produce the next state, not to decide whether to make a network call.const getState = () => state is fresh every render and welded to that render's state, so the mount-only effect that grabbed render 1's copy calls it forever. Fix: useCallback(() => stateRef.current, []) — one function, closing over the box instead of the value.setState(n => { send(n); return n; }) looks like a free way to see the newest state, and it is a real bug: React expects updaters to be pure and deliberately calls them twice in development Strict Mode so you notice. You send twice. Fix: getState() is the read; leave the updater to compute the next state and nothing else.getState() to be current the instant you call the setter. setCount(1); getCount(); // still 0. The box is rewritten during the next render, not by the setter, so within one synchronous block the getter still reports the old state. Fix: if you need the value you just computed, you already have it — the getter is for reads that happen after React has caught up, which is every read from a timer, a handler, or an await.if (state) stateRef.current = state keeps the box on its old state whenever the new one is 0, '', false or null — a counter reset to 0 would still read as the old count. Fix: assign unconditionally.getState() in the component body. During render you already have state in scope, and getState() is a longer way to spell it. The getter only earns its keep in code React isn't re-running: intervals, subscriptions, listeners, useCallback([]) bodies.useRef instead of useCallback for the getter. const getState = useRef(() => stateRef.current).current; is the paranoid version. React's docs are explicit that memoization is a performance hint, not a semantic guarantee — a future version may throw a useCallback cache away — and a getter whose stable identity you promised in the signature is where that would hurt. Nothing does today; useRef makes the guarantee yours rather than React's.useGetSet. react-use takes the other fork — keep the state in a ref, return only a getter and a setter, and have the setter force its own rerender. Nothing can go stale because there is no state value to capture, but every read in your JSX becomes get().useGetState(() => myHandler) returns myHandler as the state, not the arrow function, because useState reads a function argument as a lazy initializer. You inherit it the moment you pass the setter through untouched — the right call, but worth knowing when the state is itself a function.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.