useInterval
useInterval
from this blog post by Dan Abramov (2019):
function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
A Potential Bug
The interval callback may be invoked between the commit phase and the useEffect
invocation, causing the old (and hence not up to date) callback to be called. In other words, this may be the execution order:
- Render phase - a new value for
callback
. - Commit phase - state committed to DOM.
- useLayoutEffect
- Interval callback - using
savedCallback.current()
, which is different thancallback
. - useEffect -
savedCallback.current = callback;
React's Life Cycle
To further illustrate this, here's a diagram showing React's Life Cycle with hooks:
Dashed lines mean async flow (event loop released) and you can have an interval callback invocation at these points.
Note however, that the dashed line between Render
and React updates DOM
(commit phase) is most likely a mistake. As this codesandbox demonstrates, you can only have an interval callback invoked after useLayoutEffect
or useEffect
(but not after the render phase).
So you can set the callback in 3 places:
- Render - Incorrect because state changes have not yet been committed to the DOM.
useLayoutEffect
- correct because state changes have been committed to the DOM.useEffect
- incorrect because the old interval callback may fire before that (after layout effects) .
Demo
This bug is demonstrated in this codesandebox. To reproduce:
- Move the mouse over the grey div - this will lead to a new render with a new
callback
reference. - Typically you'll see the error thrown in less than 2000 mouse moves.
- The interval is set to 50ms, so you need a bit of luck for it to fire right between the render and effect phases.
Use Case
The demo shows that the current callback value may differ from that in useEffect
alright, but the real question is which one of them is the "correct" one?
Consider this code:
const [size, setSize] = React.useState();
const onInterval = () => {
console.log(size)
}
useInterval(onInterval, 100);
If onInterval
is invoked after the commit phase but before useEffect
, it will print the wrong value.