I hope that after reading part one about stale closures in React, you now have a good understanding of why they can occur and how to avoid them.
However, it is sometimes quite cumbersome to have to think about referential stability when passing functions around, and it can also get a bit boilerplate-y with all those additional useCallbacks.
I already left a bunch of clues in the previous article that there might be a way to make this easier, so here we go:
Photoshop
Remember this sentence?
The thing about the picture is - it cannot change. Once we have taken it, its content is sealed (unless we use photoshop).
In React, we almost always think in immutability. Props cannot be changed (from within a component), and state updates are merely scheduled and also need to happen in an immutable way. Everything we see and work with is essentially a const.
We always see the things from the time they were created, but if they are mutated in place, we would still see the latest value. Let's take the basic example from part 1 and store the value in a mutable variable outside the React component:(note: examples are interactive on my blog: https://tkdodo.eu/blog/refs-events-and-escape-hatches)
let count = 1
function App() {
const rerender = React.useReducer(() => ({}))[1]
// 😱 logCount is stable, but it still sees
// the latest value of count
const logCount = React.useCallback(() => {
console.log(count)
}, [])
return (
<div>
<div>count is {count}</div>
<button
onClick={() => {
count++
rerender()
}}
>
increment
</button>
<button onClick={logCount}>log</button>
</div>
)
}
Now it's hopefully obvious that this is not how you would work with React. Having a variable outside the component means it's shared between component instances, and React doesn't know about it, which is why we have to manually force our component to re-render.
But it showcases one thing: mutating a variable in place bypasses our mental modal of pictures that cannot change.
Mutations are literally photoshop. They can alter existing images after they have been taken. And what's the React way to hold mutable values? Refs!
Refs
Refs are just bags that can contain an arbitrary mutable value. The ref itself is referentially stable, so we don't have to include it in dependency arrays, and the .current property has the actual value. Refs offer an escape hatch from Reacts one way data flow model, because updates on refs are not going to make your component to re-render. This comes in quite handy if we have to pass functions around, e.g. to custom hooks.
As an example, let's implement a somewhat common use-case of storing a value and triggering a callback after some debounced time:
export const useDebouncedState = (callback, delay) => {
const [value, setValue] = React.useState('')
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (value) {
callback(value)
}
}, delay)
return () => {
clearTimeout(timeoutId)
}
}, [value, delay, callback])
return [value, setValue]
}
We've added callback to the effect's dependency array as the linter wants, but that has a downside: consumers will need to memoize the function, which is not a very developer friendly interface. Of course, they'll most often just want to use an inline function, without having to care about referential stability:
const [state, setState] = useDebouncedState((value) => {
alert(value)
}, 1000)
This will potentially run our effect too often, e.g. if the component re-renders for another reason. However, omitting the function from the dependency array is not an option, as it would potentially introduce stale closures.
The latest ref
I first read about a pattern called the latest ref in this article by Kent C. Dodds. The idea is to store the function (our callback) in a ref so that we can omit it from the dependency array. All we then need to do is to make sure that the ref is updated when the function changes, and we can do that with an additional effect:
export const useDebouncedState = (callback, delay) => {
const [value, setValue] = React.useState('')
// 👇 store callback in a ref
const ref = React.useRef(callback)
// 👇 update the ref when the callback changes
React.useLayoutEffect(() => {
ref.current = callback
}, [callback])
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (value) {
// 👇 use the ref instead of the callback
ref.current(value)
}
}, delay)
return () => {
clearTimeout(timeoutId)
}
// 👇 no need to include the callback
}, [value, delay])
return [value, setValue]
}
Admittedly, this does add more boilerplate, but it pays off - especially for libraries or reusable hooks due to the improved developer experience for consumers of the hook. Also, hold on tight because React might actually ship a hook that does this for us in the future:
useEvent
Recently, the React team has worked on an RFC about a dedicated hook for this behaviour called useEvent. The idea is to have a hook that is similar to useCallback, except that it doesn't have a dependency array, but still returns a stable function reference without suffering from stale closure problems.
As pointed out in the new beta docs, those event functions behave a lot more like an event handler (hence the name). The logic inside it is not reactive, and it always sees the latest values of your props and state.
Would that hook exist already, we could rewrite our useDebouncedState hook like this:
export const useDebouncedState = (callback, delay) => {
const [value, setValue] = React.useState('')
// 👇 declare the event
const onTimeout = React.useEvent(callback);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (value) {
// 👇 use the event
onTimeout(value);
}
}, delay);
return () => {
clearTimeout(timeoutId);
};
// 🎉 no need to include onTimeout
}, [value, delay]);
return [value, setValue]
}
This looks pretty clean to me, and if you want to have a user-land implementation of useEvent right now, have a look at this implementation by Diego Haz:
You can use it until React ships their own hook, but be aware that you have to include the returned function in the dependency array because unlike the native implementation, the linter cannot know that the function is stable. I personally can't wait until this ships because it will simplify a lot of my code. 🙌
That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below. ⬇️
Top comments (0)