loading...

A Concurrent Mode-Safe Version of useRef

uhyo_ profile image πŸˆšοΈγ†γ²γ‚‡πŸ€ͺ Updated on ・3 min read

When you use React Hooks, components maintain internal states for hooks. For example, caches made by useMemo and objects returned by useRef are also residents of the internal state, as well as states controlled by the useState hook. During a rendering of a component, the internal states of that component are updated. The useMemo hook should be one of the easiest example of updates during a rendering. Caches of useMemo are updated immediately during a useMemo call, if necessary.

In React's Concurrent Mode, components have possibility of suspension. That is, a rendering of a component does not necessarily result in a DOM update (or other view updates if you are not using react-dom). Every time a component suspends, modifications made during the rendering that suspended are rolled back to the state before rendering. For example, a cache newly made by useMemo is discarded if that rendering suspends.

From this characteristic of Concurrent Mode, it follows that we ought to take extra care on usage of the useRef hook. The role of useRef is very simple; it always returns the same object (ref object; more accurately, an object that is made on the first rendering of that component). This object can be utilized for communication between rendering or any other side effects that originate from a component. The point is that, modifications made to the ref object is not rolled back even if a rendering suspends.

In an article How To Properly Use the React useRef Hook in Concurrent Mode by Daishi Kato, a usage of useRef where a ref object is modified during a rendering is regarded as a Bad Code:

const BadCounter = () => {
  const count = useRef(0);
  count.current += 1;
  return <div>count:{count.current}</div>;
};

The counter's value is increased every time the BadCounter is rendered. Of note is that, in Concurrent Mode, this may not match with how many times the contents of BadCounter is reflected to the DOM.

In a worse situation the current value of a ref object may interact with other hooks during a rendering. If such a rendering suspends, the component logic may fall into an inconsistent state where the ref object's value reflects the suspended rendering's state while other hooks' state are reset.

Therefore, to involve useRef in a rendering logic, we need a concurrent-mode safe version of useRef, whose value is automatically rolled back if a rendering suspends. In other words, it is more like a variant of useState which does not trigger re-rendering.

Here it is:

type Raw<T> = {
  isRendering: boolean;
  comittedValue: T;
  currentValue: T;
  ref: { current: T };
};
export const useConcurrentModeSafeRef = <T>(initialValue: T) => {
  const rawRef = useRef<Raw<T>>();
  const raw: Raw<T> = rawRef.current ?? (
    rawRef.current ={
      isRendering: true,
      comittedValue: initialValue,
      currentValue: initialValue,
      ref: {
        get current() {
          if (raw.isRendering) {
            return raw.currentValue;
          } else {
            return raw.committedValue;
          }
        },
        set current(v) {
          if (!raw.isRendering) {
           raw.comittedValue = v;
          }
          raw.currentValue = v;
        }
      }
    }
  );
  raw.isRendering = true;
  Promise.resolve().then(()=> raw.isRendering = false)
  raw.currentValue = raw.comittedValue;
  useEffect(() => {
    raw.comittedValue = raw.currentValue;
  });

  return raw.ref;
};

This useConcurrentModeSafeRef returns an object with the same signature as useRef. The intended usage is to use ref.current as a storage that is persistent between renderings. Every time useConcurrentModeSafeRef is called, the current is reset to raw.committedValue. This value is updated only when a rendering succeeds; this behavior is realized with the help of useEffect hook. If a rendering suspends, raw.committedValue stays in the old state so that the changes made during that rendering are to be discarded.

The ref object returned by useConcurrentModeSafeRef behaves the same as useRef outside of renderings. Users can directly interact with raw.committedValue in such situations.

Conclusion

This article explained a Concurrent Mode-safe variant of useRef. The key of the Concurrent Mode safety is that the value of ref objects are rolled back if a rendering suspends for aligned behavior with other hooks.

Posted on Apr 22 by:

Discussion

markdown guide
 

Thanks for sharing!

I'm curious about this line

Promise.resolve().then(()=> raw.isRendering = false)

What guarantees do we have that the promise will only resolve in the commit phase of React?

As far as I'm aware, only useLayoutEffect is guaranteed to be ran once in the commit phase. See this video: youtube.com/watch?v=V1Ly-8Z1wQA at 16:05.

If that's correct, then we could change it to something like:

  raw.isRendering = true;
  raw.currentValue = raw.comittedValue;
  useLayoutEffect(() => {
    raw.isRendering = false;
    raw.comittedValue = raw.currentValue;
  });

And I think that would work together better with React's guarantees. What do you think?