Here is an example of a mutable ref storing the current callback from the Overreacted blog:
function useInterval(callback, delay) {
const savedCallback = useRef();
// update ref before 2nd effect
useEffect(() => {
savedCallback.current = callback; // save the callback in a mutable ref
});
useEffect(() => {
function tick() {
// can always access the most recent callback value without callback dep
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
However the React Hook FAQ states that the pattern is not recommended:
Also note that this pattern might cause problems in the concurrent mode. [...]
In either case, we don’t recommend this pattern and only show it here for completeness.
I found this pattern to be very useful in particular for callbacks and don't understand why it gets a red flag in the FAQ. For example, a client component can use useInterval without needing to wrap useCallback around the callback (simpler API).
Also there shouldn't be a problem in concurrent mode, as we update the ref inside useEffect. From my point of view, the FAQ entry might have a wrong point here (or I have misunderstood it).
So, in summary:
- Does anything fundamentally speak against storing callbacks inside mutable refs?
- Is it safe in concurrent mode when done like it is in the above code, and if not, why not?
Minor disclaimer: I'm not a core react dev and I haven't looked at the react code, so this answer is based on reading the docs (between the lines), experience, and experiment
Also this question has been asked since which explicitly notes the unexpected behaviour of the
useInterval()implementationDoes anything fundamentally speak against storing callbacks inside mutable refs?
My reading of the react docs is that this is not recommended but may still be a useful or even necessary solution in some cases hence the "escape hatch" reference, so I think the answer is "no" to this. I think it is not recommended because:
you are taking explicit ownership of managing the lifetime of the closure you are saving. You are on your own when it comes to fixing it when it gets out of date.
this is easy to get wrong in subtle ways, see below.
this pattern is given in the docs as an example of how to work around repeatedly rendering a child component when the handler changes, and as the docs say:
by e.g. using a context. This way your children are less likely to need re-rendering every time your parent is re-rendered. So in this use-case there is a better way to do it, but that will rely on being able to change the child component.
However, I do think doing this can solve certain problems that are difficult to solve otherwise, and the benefits from having a library function like
useInterval()that is tested and field-hardened in your codebase that other devs can use instead of trying to roll their own usingsetIntervaldirectly (potentially using global variables... which would be even worse) will outweigh the negatives of having useduseRef()to implement it. And if there is a bug, or one is introduced by an update to react, there is just one place to fix it.Also it might be that your callback is safe to call when out of date anyway, because it may just have captured unchanging variables. For example, the
setStatefunction returned byuseState()is guaranteed not to change, see the last note in this, so as long as your callback is only using variables like that, you are sitting pretty.Having said that, the implementation of
setInterval()that you give does have a flaw, see below, and for my suggested alternative.Is it safe in concurrent mode, when done like in above code (if not, why)?
Now I don't exactly know how concurrent mode works (and it's not finalized yet AFAIK), but my guess would be that the window condition below may well be exacerbated by concurrent mode, because as I understand it it may separate state updates from renders, increasing the window condition that a callback that is only updated when a
useEffect()fires (i.e. on render) will be called when it is out of date.Example showing that your
useIntervalmay pop when out of date.In the below example I demonstrate that the
setInterval()timer may pop betweensetState()and the invocation of theuseEffect()which sets the updated callback, meaning that the callback is invoked when it is out of date, which, as per above, may be OK, but it may lead to bugs.In the example I've modified your
setInterval()so that it terminates after some occurrences, and I've used another ref to hold the "real" value ofnum. I use twosetInterval()s:numas stored in the ref and in the render function local variable.num, at the same time updating the value innumRefand callingsetNum()to cause a re-render and update the local variable.Now, if it were guaranteed that on calling
setNum()theuseEffect()s for the next render would be immediately called, we would expect the new callback to be installed instantly and so it wouldn't be possible to call the out of date closure. However the output in my browser is something like:And each time the numbers are different illustrates the callback has been called after the
setNum()has been called, but before the new callback has been configured by the firstuseEffect().With more trace added the order for the discrepancy logs was revealed to be:
setNum()is called,render()occursuseEffect()updating ref is called.I.e. the timer pops unexpectedly between the
render()and theuseEffect()which updates the timer callback function.Obviously this is a contrived example, and in real life your component might be much simpler and not actually be able to hit this window, but it's at least good to be aware of it!
Alternative
useInterval()that does not have the same problem.The key thing with react is always to know when your handlers / closures are being called. If you use
setInterval()naively with arbitrary functions then you are probably going to have trouble. However, if you ensure your handlers are only called when theuseEffect()handlers are called, you will know that they are being called after all state updates have been made and you are in a consistent state. So this implementation does not suffer in the same way as the above one, because it ensures the unsafe handler is called inuseEffect(), and only calls a safe handler fromsetInterval():