Good to knowAdvanced React Patterns · 02

The Latest Ref Pattern

A function built once that still calls the newest callback: debounce done right, and useEffectEvent.

On this page · 8 sections
  1. In one minute
  2. The example: a debounced gift note
  3. Inside effects: useEffectEvent
  4. How it works
  5. Common mistakes
  6. Interview Q&A
  7. Related
  8. Sources

In one minute

Each render’s functions see that render’s props and state. Usually that’s a feature: an async handler can’t suddenly see values from a later render. But some functions are created once and called much later: a debounced save, a timer callback, a subscription. They need the latest values when they run. The latest ref pattern keeps the newest callback in a ref (callbackRef.current = callback after every render), and the long-lived function calls callbackRef.current(...) at call time. Its identity stays stable, but it always runs the newest code. Inside effects, React 19.2’s useEffectEvent does the same job for you.

You’ll be able to: write a useDebounce hook that neither fires too often nor saves stale values, and know when to use useEffectEvent instead.

[callback, delay]
GiftNote.tsx
1function GiftNote() {2  const [product, setProduct] = useState('Ceramic Mug')3  const saveNote = text => log(`saved "${text}" for ${product}`)4  const debouncedSave = useDebounce(saveNote, 300)5  return <input onChange={e => debouncedSave(e.target.value)} />6}78function useDebounce(callback, delay) {9  return useMemo(10    () => debounce(callback, delay),11    [callback, delay],12  )13}
What you see
Product: Desk Lamp (picked first)
Saved (recorded)
saved "G" for Desk Lamp
saved "Gi" for Desk Lamp
saved "Gif" for Desk Lamp
saved "Gift" for Desk Lamp
  1. eventreturn <input onChange={e => debouncedSave(e.target.value)} />The first key, “G”, starts a 300 ms timer inside the debounced function.
  2. render phase[callback, delay],The state change re-renders the note. saveNote is a new function, so useMemo builds a new debounced function, with its own, empty timer.
  3. eventreturn <input onChange={e => debouncedSave(e.target.value)} />“i”, “f”, “t”: each key calls the newest function, which can’t cancel the timers the older ones started.
  4. effectsconst saveNote = text => log(`saved "${text}" for ${product}`)300 ms later, four timers fire.

0 / 0
A gift note saves itself 300 ms after you stop typing. The shopper picks “Desk Lamp”, then types “Gift” quickly (all three hooks recorded).

The example: a debounced gift note

The cart has a gift note field that saves itself 300 ms after you stop typing. The save also needs to know which product the note is for. The shopper picks “Desk Lamp”, then types “Gift” quickly.

function debounce<A extends unknown[]>(fn: Fn<A>, ms: number): Fn<A> {
	let timer: ReturnType<typeof setTimeout> | undefined
	return (...args) => {
		clearTimeout(timer)
		timer = setTimeout(() => fn(...args), ms)
	}
}
function GiftNote({ useDebounceHook }: { useDebounceHook: typeof useDebounce }) {
	const [product, setProduct] = useState('Ceramic Mug')
	const [note, setNote] = useState('')
	const saveNote = (text: string) => log(`saved "${text}" for ${product}`)
	const debouncedSave = useDebounceHook(saveNote, 300)

	return (
		<>
			<select id="product" value={product} onChange={(e) => setProduct(e.target.value)}>
				<option>Ceramic Mug</option>
				<option>Desk Lamp</option>
			</select>
			<input
				id="note"
				value={note}
				onChange={(e) => {
					setNote(e.target.value)
					debouncedSave(e.target.value)
				}}
			/>
		</>
	)
}

1. Rebuild when the callback changes

function useDebounceRebuild<A extends unknown[]>(callback: Fn<A>, delay: number) {
	return useMemo(() => debounce(callback, delay), [callback, delay])
}
saved "G" for Desk Lamp
saved "Gi" for Desk Lamp
saved "Gif" for Desk Lamp
saved "Gift" for Desk Lamp

Four saves instead of one. saveNote is a new function on every render, so useMemo builds a new debounced function each time, and a new one can’t cancel the timer an old one started. Wrapping saveNote in useCallback doesn’t help: it depends on product, so it changes too, just less often.

2. Build it once

function useDebounceOnce<A extends unknown[]>(callback: Fn<A>, delay: number) {
	// eslint-disable-next-line react-hooks/exhaustive-deps
	return useMemo(() => debounce(callback, delay), [delay])
}
saved "Gift" for Ceramic Mug

One save now, but for the wrong product. The debounced function wraps the saveNote from the first render, when the product was still Ceramic Mug. That’s a stale closure.

3. The latest ref

function useDebounce<A extends unknown[]>(callback: Fn<A>, delay: number) {
	const callbackRef = useRef(callback)
	useEffect(() => {
		callbackRef.current = callback // after every render: the latest callback
	})
	return useMemo(() => debounce((...args: A) => callbackRef.current(...args), delay), [delay])
}
saved "Gift" for Desk Lamp

One save, right product. The debounced function is built once (deps [delay]), so its timer survives re-renders. It doesn’t hold callback: it holds the ref, and reads callbackRef.current when the timer fires.

Don’t write debounce(callbackRef.current, delay). That reads .current once, when useMemo runs, which is the same bug as version 2. The extra arrow function is what delays the read.

Inside effects: useEffectEvent

A stock watcher checks the product’s stock every 200 ms. Clicking “Watch Desk Lamp” changes the product prop.

function StockWatchStale({ product }: { product: string }) {
	useEffect(() => {
		log('watch started')
		const id = setInterval(() => log(`checking stock: ${product}`), 200)
		return () => (log('watch stopped'), clearInterval(id))
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [])
	return null
}

Before and after the click:

watch started
checking stock: Ceramic Mug   (repeats)
checking stock: Ceramic Mug   (repeats)

Stale: still checking the mug. Adding product to the dependencies fixes the value, but restarts the interval on every change:

function StockWatchRestart({ product }: { product: string }) {
	useEffect(() => {
		log('watch started')
		const id = setInterval(() => log(`checking stock: ${product}`), 200)
		return () => (log('watch stopped'), clearInterval(id))
	}, [product])
	return null
}
watch stopped
watch started
checking stock: Desk Lamp   (repeats)

useEffectEvent separates the two. The effect sets up the interval once; the event function always sees the latest product:

function StockWatch({ product }: { product: string }) {
	const onTick = useEffectEvent(() => log(`checking stock: ${product}`))
	useEffect(() => {
		log('watch started')
		const id = setInterval(() => onTick(), 200)
		return () => (log('watch stopped'), clearInterval(id))
	}, [])
	return null
}
checking stock: Desk Lamp   (repeats)

No restart, and the new product. useEffectEvent is meant to be called only from effects (and things they set up, like this interval). It’s not a general stable-callback tool for event handlers or props, which is where the hand-written ref is still the answer.

How it works

Common mistakes

Interview Q&A

What problem does the latest ref pattern solve?

A function that’s created once but called later (a debounced or throttled function, a timer or subscription callback) needs the latest props and state when it runs. Rebuilding it on every change loses its internal state (pending timers); not rebuilding it leaves it with stale values.

Walk through the buggy useDebounce.

useMemo(() => debounce(callback, delay), [callback, delay]) builds a new debounced function whenever callback changes, which is every render for an inline function. Each new function has its own timer, so older timers are never cancelled. Recorded: typing “Gift” saved four times.

Why doesn't useCallback fix it?

The callback still changes whenever its own dependencies change (here, product), so the debounced function is still rebuilt at those moments. It also pushes the burden onto every caller.

How does the ref version work?

An effect copies the newest callback into callbackRef.current after every render. The debounced function is built once and calls callbackRef.current(...args) when the timer fires. Recorded: one save, "Gift" for Desk Lamp.

Why write the ref in an effect and not in the render body?

Render should be pure. React can render without committing (Strict Mode, interrupted concurrent renders), and writing during such a render would store a callback from a render that never happened.

What is useEffectEvent?

A React 19.2 hook that wraps a function so it always sees the latest props and state, without being an effect dependency. It’s the built-in version of this pattern for code called from effects. Recorded: the stock watcher switched to Desk Lamp without restarting its interval.

Sources

Primary source: epicreact.dev/the-latest-ref-pattern-in-react