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.
Top comments (5)
Thanks for sharing!
I'm curious about this line
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:
And I think that would work together better with React's guarantees. What do you think?
Agree 👍
Also, if we assume this, what happens to time slicing?
This doesn't work guys. Have you tested it?
Hihi, here is AFFiNE, a new open-sourced project for Miro & Notion alternative, built with typescript & react, and our Github is here. It would be our honor if you can have a try and give us any suggetsion & feedback. Hope this would not bother you too much.
Very interesting. This might be a naive question, but shouldn't React provide this "safety" as part of the useRef hook? Don't think React users should be in charge of doing this as most will not even be aware of the issue until they need to troubleshoot very obscure bugs...