useRafState is a drop-in replacement for useState whose setter waits for the next animation frame before committing, so a burst of updates fired within the same frame collapses into a single render carrying the last value. It exists for high-frequency event streams — mousemove, scroll, resize, drag — where a plain useState re-renders once per event and the UI stutters. With useRafState you render at most once per frame (roughly every 16ms at 60fps) no matter how many events arrived. This mirrors the hook of the same name in react-use.
You return the same [state, setState] pair as useState, but the second element schedules its work with requestAnimationFrame instead of committing right away.
function useRafState<S>(
initialState: S | (() => S),
): [S, (value: S | ((prev: S) => S)) => void];
// same shape as useState — only the setter's timing changes
Tracking the pointer without a render per event:
function useMousePosition() {
const [pos, setPos] = useRafState({ x: 0, y: 0 });
useEffect(() => {
const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return pos; // commits at most once per frame, not once per event
}
Many sets in one frame commit once, with the last value:
const [n, setN] = useRafState(0);
setN(1);
setN(2);
setN(3);
// n is still 0 this frame; after the frame fires it is 3 — one render, not three
setState(prev => next) receives the last committed state at the moment the frame fires, so coalesced updaters in one frame do not stack.setState never runs after teardown.useRafState is the wrong tool — delaying the commit is the whole point.We build a useState twin whose setter parks the new value until the browser's next repaint, so a flurry of updates in one frame turns into a single render.
You are tracking the mouse. Every mousemove fires your setter, and a plain useState commits each one — so a fast drag across the screen can fire dozens of updates in the time it takes the screen to repaint once, and React re-renders on every single one. The screen can only show 60 frames a second (about one every 16ms); rendering more often than that is pure waste that makes the drag feel heavy. You want to keep the newest value but only actually commit it once per frame.
The browser repaints on a steady heartbeat — roughly 60 times a second. requestAnimationFrame is how you say "run this callback right before the next repaint." So instead of committing every set immediately, you schedule the commit for the next frame. If five more sets arrive before that frame, you throw away the frame you scheduled and schedule a fresh one for the newest value. When the frame finally fires, only the last value is left — one render instead of five.
The obvious version just hands back useState unchanged and hopes the name does something:
function useRafState(initialState) {
const [state, setState] = useState(initialState);
// "defer to a frame" ... but this still commits immediately
return [state, setState];
}
This is a plain useState. Each call to the setter commits synchronously, so a drag that fires 30 mousemove events in one frame causes up to 30 renders — the exact stutter we were trying to avoid. Nothing is deferred and nothing is coalesced. We need the setter to wait for the frame.
const { useState, useRef, useCallback, useEffect } = require('react');
function useRafState(initialState) {
const frame = useRef(0); // id of the frame we've scheduled but not yet run
const [state, setState] = useState(initialState);
const setRafState = useCallback((value) => {
cancelAnimationFrame(frame.current); // drop a frame that hasn't fired yet
frame.current = requestAnimationFrame(() => {
setState(value); // commit on the frame; value may be a plain value or an updater
});
}, []);
// Cancel a still-pending frame on unmount so setState never runs after teardown.
useEffect(() => () => cancelAnimationFrame(frame.current), []);
return [state, setRafState];
}
module.exports = { useRafState };
Three pieces do the work. A ref — a mutable box that survives re-renders without causing them — holds the id of the frame we last scheduled. setRafState first calls cancelAnimationFrame on that id to throw away any frame that has not fired yet, then schedules a new one and records its id. Because we cancel before we schedule, there is never more than one frame in flight. The setter is wrapped in useCallback with an empty dependency list so its identity is stable across renders, exactly like the setter useState gives you. value is passed straight through to setState, so a plain value and a functional updater prev => next both work. (react-use factors the unmount cleanup into a small useUnmount helper; a cleanup-only useEffect with an empty dependency array is the same thing.)
Say three mousemove events fire in one frame, calling setRafState(1), then setRafState(2), then setRafState(3):
set(1) — cancelAnimationFrame(frame.current) runs with frame.current still 0 (the initial ref value), a harmless no-op. We schedule callback #1 and store frame.current = 1.set(2) — cancelAnimationFrame(1) throws away callback #1, so it will never run. We schedule callback #2 and store frame.current = 2.set(3) — cancelAnimationFrame(2) throws away #2. We schedule callback #3 and store frame.current = 3.setState(3). React commits once and the component renders with 3. Callbacks #1 and #2 were cancelled, so two renders never happened.There is a window where a value has been set but its frame has not fired yet. If the component unmounts during that window, the frame is still scheduled — and when it fires it calls setState on a component that no longer exists. The empty-dependency cleanup effect closes that window: on unmount it runs cancelAnimationFrame(frame.current), so the pending callback is dropped and setState never runs after teardown.
setState renders on every event — the naive version above. The whole fix is to schedule the commit with requestAnimationFrame instead of running it now.cancelAnimationFrame(frame.current) before scheduling a new one.setState runs on nothing. React 19 does this silently (no warning), so the leak is invisible in dev — cancel in a cleanup effect anyway.setRafState(5) does not make state equal 5 on the next line — it is still the old value until the frame fires. If you need the value immediately, keep it in a ref alongside the state.set(prev => prev + 1) calls in the same frame commit +1, not +2 — the first frame is cancelled, so only the last updater runs, against the last committed value. If you need every event to accumulate, useRafState is the wrong tool.useThrottle or lodash.throttle instead.useRafLoop-style hook that re-schedules itself, not a one-shot commit like this.requestAnimationFrame does not exist in plain Node or during SSR; a production-grade version feature-detects it and falls back to setTimeout so it does not throw off the browser.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useRafState is a drop-in replacement for useState whose setter waits for the next animation frame before committing, so a burst of updates fired within the same frame collapses into a single render carrying the last value. It exists for high-frequency event streams — mousemove, scroll, resize, drag — where a plain useState re-renders once per event and the UI stutters. With useRafState you render at most once per frame (roughly every 16ms at 60fps) no matter how many events arrived. This mirrors the hook of the same name in react-use.
You return the same [state, setState] pair as useState, but the second element schedules its work with requestAnimationFrame instead of committing right away.
function useRafState<S>(
initialState: S | (() => S),
): [S, (value: S | ((prev: S) => S)) => void];
// same shape as useState — only the setter's timing changes
Tracking the pointer without a render per event:
function useMousePosition() {
const [pos, setPos] = useRafState({ x: 0, y: 0 });
useEffect(() => {
const onMove = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return pos; // commits at most once per frame, not once per event
}
Many sets in one frame commit once, with the last value:
const [n, setN] = useRafState(0);
setN(1);
setN(2);
setN(3);
// n is still 0 this frame; after the frame fires it is 3 — one render, not three
setState(prev => next) receives the last committed state at the moment the frame fires, so coalesced updaters in one frame do not stack.setState never runs after teardown.useRafState is the wrong tool — delaying the commit is the whole point.We build a useState twin whose setter parks the new value until the browser's next repaint, so a flurry of updates in one frame turns into a single render.
You are tracking the mouse. Every mousemove fires your setter, and a plain useState commits each one — so a fast drag across the screen can fire dozens of updates in the time it takes the screen to repaint once, and React re-renders on every single one. The screen can only show 60 frames a second (about one every 16ms); rendering more often than that is pure waste that makes the drag feel heavy. You want to keep the newest value but only actually commit it once per frame.
The browser repaints on a steady heartbeat — roughly 60 times a second. requestAnimationFrame is how you say "run this callback right before the next repaint." So instead of committing every set immediately, you schedule the commit for the next frame. If five more sets arrive before that frame, you throw away the frame you scheduled and schedule a fresh one for the newest value. When the frame finally fires, only the last value is left — one render instead of five.
The obvious version just hands back useState unchanged and hopes the name does something:
function useRafState(initialState) {
const [state, setState] = useState(initialState);
// "defer to a frame" ... but this still commits immediately
return [state, setState];
}
This is a plain useState. Each call to the setter commits synchronously, so a drag that fires 30 mousemove events in one frame causes up to 30 renders — the exact stutter we were trying to avoid. Nothing is deferred and nothing is coalesced. We need the setter to wait for the frame.
const { useState, useRef, useCallback, useEffect } = require('react');
function useRafState(initialState) {
const frame = useRef(0); // id of the frame we've scheduled but not yet run
const [state, setState] = useState(initialState);
const setRafState = useCallback((value) => {
cancelAnimationFrame(frame.current); // drop a frame that hasn't fired yet
frame.current = requestAnimationFrame(() => {
setState(value); // commit on the frame; value may be a plain value or an updater
});
}, []);
// Cancel a still-pending frame on unmount so setState never runs after teardown.
useEffect(() => () => cancelAnimationFrame(frame.current), []);
return [state, setRafState];
}
module.exports = { useRafState };
Three pieces do the work. A ref — a mutable box that survives re-renders without causing them — holds the id of the frame we last scheduled. setRafState first calls cancelAnimationFrame on that id to throw away any frame that has not fired yet, then schedules a new one and records its id. Because we cancel before we schedule, there is never more than one frame in flight. The setter is wrapped in useCallback with an empty dependency list so its identity is stable across renders, exactly like the setter useState gives you. value is passed straight through to setState, so a plain value and a functional updater prev => next both work. (react-use factors the unmount cleanup into a small useUnmount helper; a cleanup-only useEffect with an empty dependency array is the same thing.)
Say three mousemove events fire in one frame, calling setRafState(1), then setRafState(2), then setRafState(3):
set(1) — cancelAnimationFrame(frame.current) runs with frame.current still 0 (the initial ref value), a harmless no-op. We schedule callback #1 and store frame.current = 1.set(2) — cancelAnimationFrame(1) throws away callback #1, so it will never run. We schedule callback #2 and store frame.current = 2.set(3) — cancelAnimationFrame(2) throws away #2. We schedule callback #3 and store frame.current = 3.setState(3). React commits once and the component renders with 3. Callbacks #1 and #2 were cancelled, so two renders never happened.There is a window where a value has been set but its frame has not fired yet. If the component unmounts during that window, the frame is still scheduled — and when it fires it calls setState on a component that no longer exists. The empty-dependency cleanup effect closes that window: on unmount it runs cancelAnimationFrame(frame.current), so the pending callback is dropped and setState never runs after teardown.
setState renders on every event — the naive version above. The whole fix is to schedule the commit with requestAnimationFrame instead of running it now.cancelAnimationFrame(frame.current) before scheduling a new one.setState runs on nothing. React 19 does this silently (no warning), so the leak is invisible in dev — cancel in a cleanup effect anyway.setRafState(5) does not make state equal 5 on the next line — it is still the old value until the frame fires. If you need the value immediately, keep it in a ref alongside the state.set(prev => prev + 1) calls in the same frame commit +1, not +2 — the first frame is cancelled, so only the last updater runs, against the last committed value. If you need every event to accumulate, useRafState is the wrong tool.useThrottle or lodash.throttle instead.useRafLoop-style hook that re-schedules itself, not a one-shot commit like this.requestAnimationFrame does not exist in plain Node or during SSR; a production-grade version feature-detects it and falls back to setTimeout so it does not throw off the browser.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.