A four-line hook that kills stale closures without making your effects run again — and why the update happens after commit, not during render.
The Problem
You have an effect that should run once. Inside it, you read something that changes on every render — a prop, a callback, a config object. React hands you two options, and both are wrong.
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const socket = new WebSocket(`/ws/${roomId}`);
socket.onmessage = (event) => {
const message = JSON.parse(event.data) as Message;
setMessages((prev) => [...prev, message]); // ✅ setter is stable, updater sees latest
};
return () => socket.close();
}, [roomId]);
}
The setter above is safe: setMessages never changes identity, and the functional updater receives the latest state instead of a captured copy. Now change one thing: the component needs to call a prop.
socket.onmessage = (event) => {
onMessage(JSON.parse(event.data)); // ⚠️ which `onMessage`?
};
That closure captured the onMessage from the render that created the socket. Every message after the first render goes to the original handler — the one that saw the old room, the old user, the empty message list. Nothing throws. The UI just quietly drifts out of sync.
So you try the fixes, in order:
Fix attempt 1 — add the dependency.
- }, [roomId]);
+ }, [roomId, onMessage]);
Now every parent render that passes an inline arrow tears down the socket and reconnects. You get a reconnect storm, dropped messages during the gap, and a server that marks the user offline and online over and over.
Fix attempt 2 — silence the linter.
- }, [roomId, onMessage]);
+ }, [roomId]); // eslint-disable-line react-hooks/exhaustive-deps
The lint warning is gone. The bug is not. You've just moved it somewhere it's harder to find.
Fix attempt 3 — wrap everything in useCallback.
You memoize onMessage, then its parent memoizes its props, then someone passes items.filter(...) as a prop and the chain breaks. You now maintain a dependency graph by hand, and the socket still reconnects whenever a parent re-renders.
The real problem isn't the dependency array. It's that you need two different things from the same value: a stable identity so the subscription never restarts, and the current value when the callback eventually fires. A closure gives you the first and loses the second. A dependency array gives you the second and loses the first.
useAsRef gives you both, because a ref has a stable identity and mutable contents.
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
The Mental Model
Think of a mailbox bolted to the component.
-
The mailbox never moves.
refis the same object on every render, forever. Effects, timers, and third-party listeners can keep a reference to it without being torn down. - The mail is replaced after every commit. The render function does not write to the mailbox. React finishes committing, then the hook drops in the freshest props.
- Readers always get yesterday's mail until today's delivery lands. Anything that reads during render sees the previous commit's value. Anything that reads from an event handler, timer, subscription, or passive effect — i.e. after commit — sees the current one.
That last point is the whole design. useAsRef is not "a ref that is always up to date." It's "a ref that is up to date by the time any real-world event can read it."
Two consequences follow directly:
- It is not a source of truth for rendering. If you need a value in JSX, use the prop.
- It is exactly right for code that runs outside React's render cycle — sockets, intervals,
ResizeObserver, editor instances,mapcallbacks,IntersectionObserver, imperative widgets.
A quick way to remember it: stable handle, fresh contents, never a re-render.
How to Use It
The hook is two small files that live in your app's hooks folder. This article assumes the common src/ layout — adapt the paths to yours:
src/
└── hooks/
├── use-as-ref.ts
└── use-isomorphic-layout-effect.ts
src/hooks/use-isomorphic-layout-effect.ts — a five-line helper that picks the right effect on the server and the client:
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };
src/hooks/use-as-ref.ts — the hook itself:
import * as React from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };
Then import it like any other hook. The examples below use the @/ alias most React setups configure; swap in whatever your project uses:
import { useAsRef } from "@/hooks/use-as-ref";
If you already have an isomorphic layout effect helper (many SSR-ready apps do), keep yours and skip the first file.
Pattern 1: A long-lived effect that needs current props
function PresenceIndicator({
roomId,
userId,
sendPing,
}: {
roomId: string;
userId: string;
sendPing: (roomId: string, userId: string) => void;
}) {
const latest = useAsRef({ roomId, userId, sendPing });
useEffect(() => {
const id = setInterval(() => {
const props = latest.current;
props.sendPing(props.roomId, props.userId);
}, 15_000);
return () => clearInterval(id);
}, [latest]); // stable, so this runs exactly once
return null;
}
The interval is created once. When the user switches rooms or the parent passes a new sendPing, the contents change but the timer doesn't. No teardown, no drift.
Grouping several values into one object (useAsRef({ roomId, userId, sendPing })) is the common case: one ref, one read, and the call site stays readable.
Pattern 2: Handing a callback to a non-React system
type ServerEvent = { type: string; payload: unknown };
function useRealtimeEvents(onEvent: (event: ServerEvent) => void) {
const onEventRef = useAsRef(onEvent);
useEffect(() => {
const socket = new WebSocket("/ws");
socket.onmessage = (message) => {
onEventRef.current(JSON.parse(message.data) as ServerEvent);
};
return () => socket.close();
}, []); // never reconnects, no matter how `onEvent` changes
}
This is the shape to reach for in any realtime provider. The socket owns its lifecycle; the ref owns the handler. Changing onEvent — even on every render — can no longer restart the connection.
Pattern 3: A stable event handler that reads current values
type StepperProps = {
value: number;
max: number;
step: number;
onChange: (value: number) => void;
};
function Stepper({ value, max, step, onChange }: StepperProps) {
const propsRef = useAsRef({ value, max, step, onChange });
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { value, max, step, onChange } = propsRef.current;
if (event.key !== "ArrowUp") return;
onChange(Math.min(value + step, max));
},
[propsRef]
);
return <div role="spinbutton" onKeyDown={handleKeyDown} />;
}
No dependency list to maintain, no stale value, and handleKeyDown is safe to pass into a memo child or an imperative library that captures it once.
Pattern 4: Read-then-write for back-to-back updates
Because the ref is writable, you can update it before scheduling a re-render. That matters when a hook has to support back-to-back functional updates in the same tick — the classic controlled/uncontrolled state hook. Keeping the current value in a ref lets the second call see what the first one just produced:
// Inside a useControllableState-style hook.
const valueRef = useAsRef(value);
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const prev = valueRef.current;
const newValue = typeof next === "function" ? next(prev) : next;
if (Object.is(newValue, prev)) return;
valueRef.current = newValue; // visible immediately to the next call
setUncontrolledValue(newValue);
},
[valueRef]
);
useAsRef will overwrite ref.current with the committed value on the next commit, so the manual write is a short-lived optimistic value, not a fork of state.
The one thing not to do
// ❌ renders the previous commit's value
return <span>{countRef.current}</span>;
Reading during render is the single misuse that turns a useful hook into a confusing one. If it's in JSX, it's a prop.
When to Use It
Reach for useAsRef when all three are true:
- Something outside React will call back later — a timer, socket, observer, event listener, or third-party instance.
- That callback needs the newest value.
- Re-subscribing to get the newest value would be expensive, disruptive, or visibly wrong (connection resets, scroll jumps, editor state loss, animation restarts).
Don't reach for it when:
| Situation | Better tool |
|---|---|
| Adding the dependency is cheap and harmless | Just add it to the array |
| You need a DOM node | useRef<HTMLDivElement>(null) |
| You need a stable function identity to pass as a prop |
useCallback, or a small wrapper that always calls the latest callback |
| You need to react to a value changing |
useEffect with that value in the deps |
| You need the value during render | Use the prop itself |
| The value is a state setter | Functional updater: setX(prev => …)
|
A useful smell test: if removing useAsRef and adding the dependency produces no user-visible behavior change, the hook was unnecessary indirection.
How It Works Under the Hood
Four lines, and four details worth understanding.
1. useRef<T>(props) — stable identity, seeded with the first value
const ref = React.useRef<T>(props);
useRef returns the same object across every render of the component's lifetime. The initial argument is only used on the first render; after that it's ignored. Seeding it with props (rather than null) means the ref is already readable before any effect fires — on the very first commit, a child's layout effect (which runs before the parent's) reads the initial props instead of null. In React 19's types, the result is a RefObject<T> whose current is mutable.
2. useIsomorphicLayoutEffect — update after commit, before paint
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
There is no dependency array, so this runs after every commit. That's intentional: you don't want to enumerate dependencies for a value you're already holding, and the assignment is a single property write — cheaper than the bookkeeping needed to skip it.
On the client, useIsomorphicLayoutEffect is useLayoutEffect. That runs synchronously after React has mutated the DOM but before the browser paints. So the sequence is:
- React renders your component (reading the ref gives the old value).
- React commits DOM changes.
- Layout effects run;
ref.currentbecomes the new value. - Browser paints.
- Passive
useEffectcallbacks run.
By the time a user can click, scroll, or receive a socket frame, the ref is current. Using a passive useEffect here would leave a window where a paint has happened but the ref still holds the previous value.
3. Why not just assign during render?
If all you wanted were the latest value, ref.current = props in the render body would be simpler. It's also unsafe.
React may render a component and throw the result away — an interrupted concurrent render, a Suspense fallback, or a state update that gets superseded. Render must be pure, and mutating a ref during render mutates shared state that survives the discarded render. You'd end up with a ref holding a value that never actually committed, and a callback reading props from a UI state the user never saw.
React 18+ and StrictMode double-invocation make this worse. Assigning in a layout effect ties the write to the commit, which is the only moment the value is real.
This is the same territory React's useEvent RFC explored — the ancestor of today's useEffectEvent. useEffectEvent solves the effect-only case, but it deliberately refuses to be handed to a socket, a timer, or a third-party widget. useAsRef is the general-purpose version of that idea: not just functions, any value, usable anywhere outside render.
4. Why the effect ordering matters
React runs layout effects child-first, then the parent. So if a child's layout effect calls a callback stored in the parent's useAsRef ref, it will still see the previous render's callback — the parent's layout effect hasn't run yet. Passive effects (useEffect) always see the updated ref, because all layout effects across the tree run before any passive effect.
In practice this almost never bites: consumers of useAsRef are timers, sockets, and event handlers, not layout effects. But if you hit it, the fix is to keep the value as a prop in that path rather than routing it through the ref.
The timeline
Assume useAsRef(count) and a parent that increments count each click:
| Point in time |
count (prop) |
countRef.current |
|---|---|---|
| Render 1 | 0 |
0 (seed from useRef) |
| Commit 1 | 0 |
0 |
| Render 2 | 1 |
0 — old value during render |
| Commit 2 | 1 |
1 |
| Click handler after paint | 1 |
1 |
| Render 3 (discarded) | 2 |
1 — never updated, correctly |
| Commit 3 | 2 |
2 |
Cost
One ref object for the component's lifetime — it is never recreated. The hook itself does no allocation per render beyond the effect closure React already allocates; a fresh props object is created only if you pass an object literal. Then one property write per commit, zero extra renders, zero subscriptions. The ref holds the latest props between renders, so previous values are released as soon as they're replaced — no leak, though anything the latest props reach is kept alive for as long as the component is mounted.
A note on server rendering
On the server, useIsomorphicLayoutEffect resolves to useEffect, which never runs. The ref simply keeps its initial value. That's fine: no timers, sockets, or click handlers execute during SSR. The isomorphic wrapper exists to avoid React's "useLayoutEffect does nothing on the server" warning, not to make the ref work on the server.
Conclusion
Stale closures happen when a stable identity and a current value are forced to be the same thing. Dependency arrays buy freshness by throwing away the identity; refs buy identity by giving up freshness. useAsRef separates the two:
- The ref object is stable, so subscriptions, timers, and third-party listeners are created once.
- The ref contents are refreshed after every commit, before paint, so anything that reads outside render sees the current value.
- The render phase deliberately sees the old value, which keeps the hook safe under concurrent rendering and prevents it from becoming a second source of truth.
Use it where React stops and the rest of the world begins: sockets, intervals, observers, editors, and any callback that outlives a render. Everywhere else, the boring answer — a prop or a dependency — is still the right one.
Top comments (0)