DEV Community

Cover image for useCallbackRef: A Stable Callback That Never Goes Stale
Saiful Islam
Saiful Islam

Posted on

useCallbackRef: A Stable Callback That Never Goes Stale

A five-line hook that gives you one function identity for the life of a component while always calling the newest version — and why the swap waits until after paint, not during render.


The Problem

You have an autosave. It is almost right.

import { useEffect, useState } from "react";

type Draft = { id: string; body: string };

function Editor({
  draftId,
  initialBody,
  onSave,
}: {
  draftId: string;
  initialBody: string;
  onSave: (draft: Draft) => Promise<void>;
}) {
  const [body, setBody] = useState(initialBody);

  useEffect(() => {
    const id = setInterval(() => {
      void onSave({ id: draftId, body }); // ⚠️ `body` from the mount render
    }, 5000);
    return () => clearInterval(id);
  }, [draftId]);

  return (
    <textarea value={body} onChange={(event) => setBody(event.target.value)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

You type for two minutes. The save spinner blinks every five seconds. The server receives the same draft every time: the body you passed as initialBody, usually an empty string. Nothing throws. The network tab is green. The bug is invisible until someone reopens the document and finds the work gone.

So you try the fixes, in order.

Fix attempt 1 — add the value to the dependency array.

-  }, [draftId]);
+  }, [draftId, body]);
Enter fullscreen mode Exit fullscreen mode

The interval is now correct and useless. Every keystroke tears it down and builds a new one, so the timer restarts before it ever reaches five seconds. A fast typist never autosaves at all. If the parent passes an inline arrow for onSave — which it usually does — unrelated parent renders reset it too.

Fix attempt 2 — memoize the callback.

const handleSave = useCallback((draft: Draft) => onSave(draft), [onSave]);
Enter fullscreen mode Exit fullscreen mode

To earn a stable identity, the callback has to stop closing over the thing that changes. Drop body from its dependencies and you have recreated the original bug one level up. Keep body and the identity changes on every keystroke, which is the interval reset again. You have moved the same decision into a second dependency array, not made it.

Fix attempt 3 — silence the linter.

-  }, [draftId]);
+  }, [draftId]); // eslint-disable-line react-hooks/exhaustive-deps
Enter fullscreen mode Exit fullscreen mode

The warning is gone. The stale draft is not.

The real problem is not the dependency array. The timer needs one thing from the handler — a stable identity so it is never rebuilt — and the handler needs the other — current state at the moment it fires. A closure can only give you one of them, because the captured variables and the function are the same object.

A ref breaks that coupling: the ref never changes, its contents do. This hook wraps a ref in a function so the stable setInterval can call the fresh closure.

import { useEffect, useMemo, useRef } from "react";

function useCallbackRef<T extends (...args: never[]) => unknown>(
  callback: T | undefined
): T {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  });

  return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}
Enter fullscreen mode Exit fullscreen mode

The Mental Model

Think of the extension number for a hotel front desk.

  • The number never changes. useMemo(() => …, []) builds one wrapper on the first render and returns that same function until the component unmounts. Timers, sockets, and third-party widgets can store it and never invalidate it.
  • Whoever answers changes per shift. After every commit, callbackRef.current = callback replaces the body with the function from the latest render.
  • You only learn who is on duty when you dial. The wrapper forwards the call at invocation time. It does not copy the callback when it was created.
  • An empty desk just rings out. The optional call callbackRef.current?.(...) turns a undefined callback into a silent no-op instead of a crash.

Two consequences follow directly:

  1. The wrapper is not a value you can inspect. Calling it is the only way to observe the current callback; reading it during render tells you nothing.
  2. It is exactly right for code that stores a function — timers, listeners, widget constructors, memoized children — and wrong for code that has to decide whether to run based on the function identity.

A quick way to remember it: same number, whoever is on shift.


How to Use It

One file, no dependencies beyond React:

src/
└── hooks/
    └── use-callback-ref.ts
Enter fullscreen mode Exit fullscreen mode

src/hooks/use-callback-ref.ts:

import { useEffect, useMemo, useRef } from "react";

/**
 * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
 * prop or avoid re-executing effects when passed as a dependency
 */
function useCallbackRef<T extends (...args: never[]) => unknown>(
  callback: T | undefined
): T {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  });

  return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}

export { useCallbackRef };
Enter fullscreen mode Exit fullscreen mode

Import it through whatever alias your project uses; the examples below assume @/:

import { useCallbackRef } from "@/hooks/use-callback-ref";
Enter fullscreen mode Exit fullscreen mode

The one rule that makes every pattern below work: wrap the closure, not the values. The hook stabilizes a function. If a value is read outside that function, the hook cannot help you.

Pattern 1: A timer that must survive every render

type Draft = { id: string; body: string };

function useAutosave(save: () => void) {
  const saveRef = useCallbackRef(save);

  useEffect(() => {
    const id = setInterval(() => saveRef(), 5000);
    return () => clearInterval(id);
  }, [saveRef]);
}

function Editor({
  draftId,
  initialBody,
  onSave,
}: {
  draftId: string;
  initialBody: string;
  onSave: (draft: Draft) => Promise<void>;
}) {
  const [body, setBody] = useState(initialBody);

  useAutosave(() => {
    void onSave({ id: draftId, body });
  });

  return (
    <textarea value={body} onChange={(event) => setBody(event.target.value)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

The caller passes a brand-new closure on every render, which looks wasteful and is the point: the ref keeps the newest one, and the interval keeps its original identity. One timer for the life of the editor, writing whatever is in the textarea at the tick.

Pattern 2: A listener that outlives the render

function useFlushOnExit(flush: () => void) {
  const flushRef = useCallbackRef(flush);

  useEffect(() => {
    const handler = () => flushRef();
    window.addEventListener("pagehide", handler);
    return () => window.removeEventListener("pagehide", handler);
  }, [flushRef]);
}

function Editor({
  draftId,
  initialBody,
}: {
  draftId: string;
  initialBody: string;
}) {
  const [body, setBody] = useState(initialBody);

  useFlushOnExit(() => {
    navigator.sendBeacon(`/api/drafts/${draftId}`, JSON.stringify({ body }));
  });

  return (
    <textarea value={body} onChange={(event) => setBody(event.target.value)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

There is exactly one registration and no window where the page is unloading with no listener attached. The payload is assembled at unload time from the current state, not from the state at mount.

Pattern 3: A callback handed to a non-React instance

// Stand-in for a widget library that captures its callback at construction.
class Typeahead {
  constructor(
    private readonly input: HTMLInputElement,
    private onPick: ((value: string) => void) | undefined
  ) {}

  press() {
    this.onPick?.(this.input.value);
  }

  destroy() {
    this.onPick = undefined;
  }
}

function SearchField({ onPick }: { onPick: (value: string) => void }) {
  const inputRef = useRef<HTMLInputElement>(null);
  const pickRef = useCallbackRef((value: string) => onPick(value));

  useEffect(() => {
    const input = inputRef.current;
    if (!input) return;
    const widget = new Typeahead(input, (value) => pickRef(value));
    return () => widget.destroy();
  }, [pickRef]);

  return <input ref={inputRef} />;
}
Enter fullscreen mode Exit fullscreen mode

The widget is constructed once and stores a function forever. Changing onPick — even on every parent render — never forces you to destroy and rebuild it, which for a real editor, map, or chart means the undo history and viewport survive too.

Pattern 4: A stable prop for a memoized child

type Row = { id: string; label: string };

const RowItem = memo(function RowItem({
  row,
  selected,
  onSelect,
}: {
  row: Row;
  selected: boolean;
  onSelect: (id: string) => void;
}) {
  return (
    <button aria-pressed={selected} onClick={() => onSelect(row.id)}>
      {row.label}
    </button>
  );
});

function RowList({
  rows,
  onOpen,
}: {
  rows: Row[];
  onOpen: (id: string) => void;
}) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  const select = useCallbackRef((id: string) => {
    if (selectedId === id) {
      setSelectedId(null);
      return;
    }
    setSelectedId(id);
    onOpen(id);
  });

  return (
    <div>
      {rows.map((row) => (
        <RowItem
          key={row.id}
          row={row}
          selected={row.id === selectedId}
          onSelect={select}
        />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

select reads selectedId, yet its identity never changes. A useCallback with [selectedId] would hand every row a new prop each time the selection moves and defeat the memo; here only the two rows whose selected flag changed re-render.

The one thing not to do

// ❌ the wrapper is stable; `page` is still frozen at 0
const load = useCallbackRef(onLoad);

useEffect(() => {
  void load(page);
}, []);
Enter fullscreen mode Exit fullscreen mode

The hook stabilizes a function, not the variables around the call site. If the effect must react to page, put page in the dependency array. The wrapper only helps when the current page is read inside the function you hand it.


When to Use It

Reach for useCallbackRef when all three are true:

  • Something stores your function beyond the render that created it — a timer, a DOM listener, a widget instance, a memoized child.
  • When that thing fires, it needs the values from the latest render, not the ones from setup.
  • Re-subscribing to get those values would be expensive, disruptive, or visibly wrong: a reset interval, a reconnect, a lost editor history, a gap in events.

Don't reach for it when:

Situation Better tool
Adding the dependency is cheap and harmless Just add it to the array
The effect must re-run when a value changes Put that value in the dependency array
You need a DOM node useRef<HTMLDivElement>(null)
You only need a stable handler with stable deps useCallback
You are inside an effect and want the latest props useEffectEvent (React 19.2+)
You need the result during render Call the prop or compute it directly

A useful smell test: if the consumer keeps working after you add the callback to a dependency array and let it re-subscribe, this hook is extra machinery.


How It Works Under the Hood

1. useRef(callback) seeds the desk before the first shift

const callbackRef = useRef(callback);
Enter fullscreen mode Exit fullscreen mode

useRef ignores its argument after the first render and returns the same object for the component's lifetime. Seeding it with callback instead of null means the wrapper is callable immediately — during the first render, from a layout effect, from a child's effect — with the callback from that same render. A null seed would open a window where the first legitimate call is a no-op.

2. useMemo(…, []) makes the identity stable and allocates once

return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
Enter fullscreen mode Exit fullscreen mode

The factory runs on the first render and the result is cached. Every later render returns the same function object, so a consumer that stored it never needs to be told anything changed.

useRef(() => …) would be tempting, but the expression inside useRef(...) is evaluated on every render — you would allocate a throwaway closure per render just to discard it. useCallback(fn, []) is equivalent here; it compiles to useMemo(() => fn, []). React's docs describe useMemo as a performance optimization rather than a hard guarantee, but the identity is stable for the life of the mounted component, which is the contract this hook needs.

3. useEffect with no dependency array updates after paint

useEffect(() => {
  callbackRef.current = callback;
});
Enter fullscreen mode Exit fullscreen mode

No dependency array means every commit, not every render. The distinction matters: if React starts a render and throws it away — an interrupted concurrent render, a suspended tree, a superseded update — the effect never runs, so the ref still holds the last callback that actually reached the screen. Assigning callbackRef.current = callback in the render body would leak values from renders the user never saw.

This is a passive effect, so it runs after the browser paints. On the client the sequence is:

  1. React renders (a read of current here returns the previous callback).
  2. React commits the DOM.
  3. Layout effects run.
  4. Browser paints.
  5. Passive effects run and the ref receives the new callback.

A native listener or a setInterval tick can fire between steps 4 and 5, because passive effects are scheduled as a task rather than run synchronously at commit. That gap is the price of using useEffect instead of useLayoutEffect. It is short and, for the consumers this hook targets, harmless; if you need the update to land before paint, swap in useLayoutEffect (behind an SSR guard) and accept that it blocks paint.

4. The read is deferred to call time

callbackRef.current?.(...args);
Enter fullscreen mode Exit fullscreen mode

The wrapper does not close over callback; it closes over the ref. Every invocation reads current fresh, which is the entire mechanism. The optional chaining handles the undefined case: a hook that accepts T | undefined can be handed a callback that disappears (a cleared prop, a disabled feature) and calls become silent no-ops rather than TypeErrors.

That flexibility has a typing cost. If you call useCallbackRef(undefined) with no contextual type, TypeScript falls back to the constraint, and the return type is (...args: never[]) => unknown — not your handler's signature. Pass a typed variable when you need the real type back.

5. The constraint and the assertion

<T extends (...args: never[]) => unknown>
Enter fullscreen mode Exit fullscreen mode

never[] accepts any function signature. Parameter positions are checked contravariantly, and never is assignable to everything, so a (id: string, count: number) => void satisfies the constraint; unknown[] would reject it. Using any[] would also work but would leak any into inference.

as T is load-bearing. The wrapper's body cannot prove it implements an arbitrary T — TypeScript cannot spread a generic parameter tuple into a generic call — so the assertion restores the exact signature at the boundary. Two things it does not preserve: this binding (the callback is called as a plain function) and properties attached to the function object (they exist on the original, not on the wrapper).

The timeline

Assume the component receives a new onSave prop on each render:

Point in time callback prop callbackRef.current Returned function
Render 1 fn A fn A (seed) W1 (new)
Commit 1 fn A fn A W1
Passive effect 1 fn A fn A W1
Render 2 fn B fn A — old value during render W1
Paint 2 fn B fn A — not updated yet W1
Click between paint and effect fn B fn A — previous handler runs W1
Passive effect 2 fn B fn B W1
Render 3 (discarded) fn C fn B — never updated, correctly W1
Commit 3 fn C fn C W1
Unmount — released released

Effect ordering

Passive effects run child-first. If a child's useEffect calls a wrapper owned by its parent in the same commit, the parent's effect has not run yet, so the child reaches the previous render's callback. Layout effects have the mirror-image issue. This rarely matters because the hook's consumers are timers, listeners, and event handlers, not effects in the same commit — but if you hit it, move the value into a prop for that one path.

Cost

One ref and one wrapper per component instance, both alive until unmount. One effect closure allocated per commit — the effect has no dependency array, so React cannot skip it — plus one property write. Zero extra renders, zero subscriptions, no per-call allocation. The ref retains the newest callback, and therefore anything that callback closes over, until it is replaced or the component unmounts; that is the one memory consideration worth naming.

A note on server rendering

useEffect never runs on the server, so the ref keeps its seed. That is fine: nothing calls the wrapper during SSR except your own render code, and if it does, it reaches the callback from that render rather than undefined. Functions are not serialized into HTML, so there is no hydration mismatch — only the usual rule that the client's first render must match the server's, which this hook does not affect. No isomorphic-layout-effect wrapper is needed here precisely because the hook uses the passive effect.

Compared to useEffectEvent

React 19.2 ships useEffectEvent, which returns a stable function that reads the latest props and state. It covers the narrow case this hook is often reached for: calling the newest callback from inside an effect. It deliberately refuses the rest — the linter stops you from passing that function to a socket constructor, a memoized child, or an event listener registered elsewhere, because those calls happen outside the effect system it is designed around. useCallbackRef is the general-purpose version: any function, handed to anything, callable anywhere except during render.


Conclusion

The tension is not really about dependency arrays. It is that a function carries its captured values with it, so a stable function is a stale function unless something outside the closure can refresh it. useCallbackRef puts that refresh in a ref that is updated after every commit, and exposes a wrapper whose identity never changes.

  • The wrapper is stable, so timers, listeners, widgets, and memoized children are set up once.
  • The ref is refreshed after every commit, so the call that eventually fires reaches the newest closure.
  • The update is deliberately late — after paint, never during render — which keeps discarded renders from leaking into the ref and keeps the hook out of the rendering path.

Wrap the closure, not the values, and it disappears into the background. Everywhere the consumer can simply take a dependency and re-subscribe, the boring answer is still correct.

Top comments (1)

Collapse
 
davekurian profile image
Dave Kurian •

One type edge: callback: T | undefined still returns T, but the optional call can yield undefined when the ref is empty. For a callback that normally returns a value, callers can be promised string and actually get undefined. Could the hook expose that absence in its return type or require a fallback?