React: Concurrent Mode and Refs

April 07, 2021

In my post about useDebounce hook I mentioned that it’s safer to update refs inside useLayoutEffect (or useEffect) in React Concurrent Mode.

Today I wanted to explain this topic more in-depth by showing an example of wrong usage of useRef and explaining how to fix it.

What is different in Concurrent Mode?

React’s work could be separated into 2 phases:

  • render (react builds VDOM and finds what needs to be updated)
  • commit (React updates the dom)

The main difference in Concurrent Mode is that Reacts’ rendering could be done in few steps.

For instance, if you have a long list of components that you need to render, and some event is fired during that process, React could stop its rendering to handle user events.

Because of that, functional components could be invoked multiple times before a commit happens. As a result, not careful usage of useRef could lead to unexpected bugs.

Let’s look at this example:

const Component = () => {
  const countRef = useRef(0);

  // side effect
  countRef.current++;

  const onClick = useCallback(() => {
    console.log(countRef.current++);
  }, [countRef]);

  return <div onClick={onClick}>{/* ... */}</div>;
};

Imagine that this component is rendered without commit (dom updates). The countRef will update its value, but the updates are not applied yet and our dom is not synchronized with the component state.

Therefore if another click event happens, we should see the same value.

However, because refs share the same reference between renders, onClick will always use the latest countRef value, which would be incorrect until React commits changes.

The solution

The solution to this problem is simple - don’t make any side effects in your components render(function body).

const Component = () => {
  const countRef = useRef(0);

- // side effect
- countRef.current++;
+ useLayoutEffect(() => {
+   // side effect
+   countRef.current++;
+ })

  const onClick = useCallback(() => {
    console.log(countRef.current++);
  }, [countRef]);

  return <div onClick={onClick}>{/* ... */}</div>;
};

In this situation, we do it inside a useLayoutEffect, because we need an updated value as soon as the component commits its changes.

And, as we all know, useLayoutEffect is called synchronously right after the dom updates. So our onClick won’t have any time to fire with an old value.

However, in some cases, it’s not important to make a side effect ASAP. In these situations, it’d be preferred to use useEffect rather.

StrictMode

To catch side effects in your components, you could use a [StrictMode](strict mode link):

-ReactDOM.render(<App/>, document.getElementById('root'));
+ReactDOM.render(
+  <React.StrictMode>
+   <App/>
+  </React.StrictMode>,
+  document.getElementById('root')
+);

It’ll render you components twice on each update with only 1 commit to help you find edge cases in your app.

It will also add a warning for deprecated APIs that won’t work in concurrent mode.

Conclusion

So the main takeaway from this article is that you need to move your refs’ updates from the component body into effects.

If you liked this article - share it with your friends, it helps.

Peace!


© 2023 Ayub Begimkulov All Rights Reserved