Store a callback in useRef() Store a callback in useRef() reactjs reactjs

Store a callback in useRef()


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() implementation

Does 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:

    it is preferable to avoid passing callbacks deep down

    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 using setInterval directly (potentially using global variables... which would be even worse) will outweigh the negatives of having used useRef() 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 setState function returned by useState() 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 useInterval may pop when out of date.

In the below example I demonstrate that the setInterval() timer may pop between setState() and the invocation of the useEffect() 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 of num. I use two setInterval()s:

  • one simply logs the value of num as stored in the ref and in the render function local variable.
  • the other periodically updates num, at the same time updating the value in numRef and calling setNum() to cause a re-render and update the local variable.

Now, if it were guaranteed that on calling setNum() the useEffect()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:

[Log] interval pop 0 0 (main.chunk.js, line 62)[Log] interval pop 0 1 (main.chunk.js, line 62, x2)[Log] interval pop 1 1 (main.chunk.js, line 62, x3)[Log] interval pop 2 2 (main.chunk.js, line 62, x2)[Log] interval pop 3 3 (main.chunk.js, line 62, x2)[Log] interval pop 3 4 (main.chunk.js, line 62)[Log] interval pop 4 4 (main.chunk.js, line 62, x2)

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 first useEffect().

With more trace added the order for the discrepancy logs was revealed to be:

  1. setNum() is called,
  2. render() occurs
  3. "interval pop" log
  4. useEffect() updating ref is called.

I.e. the timer pops unexpectedly between the render() and the useEffect() 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!

import { useEffect, useRef, useState } from 'react';function useInterval(callback, delay, maxOccurrences) {  const occurrencesRef = useRef(0);  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();      occurrencesRef.current += 1;      if (occurrencesRef.current >= maxOccurrences) {        console.log(`max occurrences (delay ${delay})`);        clearInterval(id);      }    }    let id = setInterval(tick, delay);    return () => clearInterval(id);  }, [delay]);}function App() {  const [num, setNum] = useState(0);  const refNum = useRef(num);  useInterval(() => console.log(`interval pop ${num} ${refNum.current}`), 0, 60);  useInterval(() => setNum((n) => {    refNum.current = n + 1;    return refNum.current;  }), 10, 20);  return (    <div className="App">      <header className="App-header">        <h1>Num: </h1>      </header>    </div>  );}export default App;

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 the useEffect() 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 in useEffect(), and only calls a safe handler from setInterval():

import { useEffect, useRef, useState } from 'react';function useTicker(delay, maxOccurrences) {  const [ticker, setTicker] = useState(0);  useEffect(() => {    const timer = setInterval(() => setTicker((t) => {      if (t + 1 >= maxOccurrences) {        clearInterval(timer);      }      return t + 1;    }), delay);    return () => clearInterval(timer);  }, [delay]);  return ticker;}function useInterval(cbk, delay, maxOccurrences) {  const ticker = useTicker(delay, maxOccurrences);  const cbkRef = useRef();  // always want the up to date callback from the caller  useEffect(() => {    cbkRef.current = cbk;  }, [cbk]);  // call the callback whenever the timer pops / the ticker increases.  // This deliberately does not pass `cbk` in the dependencies as   // otherwise the handler would be called on each render as well as   // on the timer pop  useEffect(() => cbkRef.current(), [ticker]);}


Does anything fundamentally speak against storing callbacks insidemutable refs?

No. React is not against storing callbacks inside mutable refs. Here is the way they recommend to store callbacks inside mutable refs.https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node

function MeasureExample() {  const [height, setHeight] = useState(0);  const measuredRef = useCallback(node => {    if (node !== null) {      setHeight(node.getBoundingClientRect().height);    }  }, []);  return (    <>      <h1 ref={measuredRef}>Hello, world</h1>      <h2>The above header is {Math.round(height)}px tall</h2>    </>  );}

Is it safe in concurrent mode when done like it is in the above code,and if not, why not?

Not sure whether it is really safe or not. If it is not recommended, I believe it's rather about the purpose of using useRef because the whole point is not to set refs on re-renders. Therefore, the use of useEffect is not appropriate expression though the current code might not cause the problems. In other words, React doesn't test the problems that can be caused in concurrent mode either since using useRef in that particular way is against their practice.