Skip to content

๐Ÿ’ญ React Latest Ref Pattern

I wasnโ€™t a fan of React until it introduced hooks, especially useEffect(), which makes lifecycle management much cleaner compared to all other client-side platforms Iโ€™ve worked with, as the initialization code and its cleanup code are colocated.

Lifecycle methods
onInit() {
init_block_1
init_block_2
init_block_3
...
}
onDestroy() {
cleanup_block_1
cleanup_block_2
cleanup_block_3
...
}
useEffect()
useEffect(() => {
init_block_1
return cleanup_block_1
}, [])
useEffect(() => {
init_block_2
return cleanup_block_2
}, [])
useEffect(() => {
init_block_3
return cleanup_block_3
}, [])
...

The Problem

However, the APIโ€™s cleanliness comes with a cost. The dependency array is confusing and error-prone. If something is missed, the effect may not get triggered when it should, or it may use stale values in the effect closure.

The React team turned to linters and created the โ€œreact-hooks/exhaustive-depsโ€ ESLint rule, which brings its own problem of triggering effects more than necessary. I wish the useEffect() API was designed with separate parameters for:

  • Triggering the effect (the dependency array)
  • Updating the values used inside the effect (another parameter)

The Solution

So I started to use the dependency array only to trigger the effect and used the useRef() hook to get the latest value inside the effect.

I learned from Epic React by Kent C. Dodds that this is called the Latest Ref pattern.

And I implemented the API I wished for:

useLatestRefEffect()
import { type DependencyList, useEffect, useRef } from 'react'
export function useLatestRefEffect<const T extends { [key: string]: any }>(
effect: (latest: T) => ReturnType<typeof useEffect>,
deps: DependencyList,
latest: T,
) {
let latestRef = useRef(latest)
let proxyRef = useRef<T>()
useEffect(() => {
latestRef.current = latest
})
useEffect(() => {
if (!proxyRef.current) {
proxyRef.current = new Proxy(latestRef, {
get: (target, p) => target.current[p as keyof T],
}) as unknown as T
}
return effect(proxyRef.current)
}, deps)
}
Usage
useLatestRefEffect(
(latest) => {
// latest.value is always up to date
},
[dep], // Triggers the effect
{ value }, // Only update value
)

Example

Here is a simple example, which validates the userโ€™s input asynchronously, with a mock check that returns true if the length of the value is an odd number, in a random duration between 200ms and 1s.

Checking...

It only triggers the async validation when the input value changes, so we only want value in the dependency array.

Once the result comes back, we want to use the latest values in the callback.

There are other ways to achieve this and some may be simpler. Below is just to showcase the usage of useLatestRefEffect(), which separates the concerns to re-trigger the effect, and keep the values used inside the effect up to date.

export function LatestRefExample() {
let [value, setValue] = useState('')
let [result, setResult] = useState<boolean | null>(null)
useLatestRefEffect(
(latest) => {
setResult(null)
check(value).then(
(valid) => latest.value == value && latest.setResult(valid),
)
},
[value],
{ value, setResult },
)
return (
<div>
<label>
Value:
<input value={value} onChange={(e) => setValue(e.target.value)} />
</label>
<span>{result == null ? 'Checking...' : `Result: ${result}`}</span>
</div>
)
}
async function check(handle: string) {
await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 800))
return handle.length % 2 == 1
}