Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Store a callback in useRef()

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:

  1. Does anything fundamentally speak against storing callbacks inside mutable refs?
  2. Is it safe in concurrent mode when done like it is in the above code, and if not, why not?
like image 531
bela53 Avatar asked May 25 '20 09:05

bela53


People also ask

How do I store objects in useRef?

Storing element references with useRef To do this, create the ref, and then pass it into the element: const Component = () => { const ref = useRef(null); return <div ref={ref}> Hello world </div>; }; With this reference, you can do lots of useful things like: Grabbing an element's height and width.

How do you use callback in React?

The useCallback hook is used when you have a component in which the child is rerendering again and again without need. Pass an inline callback and an array of dependencies. useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed.

Can we use useRef in useEffect?

In the useEffect , we are updating the useRef current value each time the inputValue is updated by entering text into the input field.

What is a callback ref?

Callback RefsThe function receives the React component instance or HTML DOM element as its argument, which can be stored and accessed elsewhere. The example below implements a common pattern: using the ref callback to store a reference to a DOM node in an instance property.


1 Answers

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]);
}
like image 64
daphtdazz Avatar answered Sep 18 '22 20:09

daphtdazz