Is it safe to change a ref's value during render instead of in useEffect?

2.5k Views Asked by At

I'm using useRef to hold the latest value of a prop so that I can access it later in an asynchronously-invoked callback (such as an onClick handler). I'm using a ref instead of putting value in the useCallback dependencies list because I expect the value will change frequently (when this component is re-rendered with a new value), but the onClick handler will be called rarely, so it's not worth assigning a new event listener to an element each time the value changes.

function MyComponent({ value }) {
  const valueRef = useRef(value);
  valueRef.current = value;  // is this ok?

  const onClick = useCallback(() => {
    console.log("the latest value is", valueRef.current);
  }, []);

  ...
}

The documentation for React Strict Mode leads me to believe that performing side effects in render() is generally unsafe.

Because the above methods [including class component render() and function component bodies] might be called more than once, it’s important that they do not contain side-effects. Ignoring this rule can lead to a variety of problems, including memory leaks and invalid application state.

And in fact I have run into problems in Strict Mode when I was using a ref to access an older value.

My question is: Is there any concern with the "side effect" of assigning valueRef.current = value from the render function? For example, is there any situation where the callback would receive a stale value (or a value from a "future" render that hasn't been committed)?

One alternative I can think of would be a useEffect to ensure the ref is updated after the component renders, but on the surface this looks unnecessary.

function MyComponent({ value }) {
  const valueRef = useRef(value);
  useEffect(() => {
    valueRef.current = value;  // is this any safer/different?
  }, [value]);

  const onClick = useCallback(() => {
    console.log("the latest value is", valueRef.current);
  }, []);

  ...
}
3

There are 3 best solutions below

4
On BEST ANSWER

For example, is there any situation where the callback would receive a stale value (or a value from a "future" render that hasn't been committed)?

The parenthetical is the primary concern.

There's currently a one-to-one correspondence between render (and functional component) calls and actual DOM updates. (i.e. committing)

But for a long time the React team has been talking about a "Concurrent Mode" where an update might start (render gets called), but then get interrupted by a higher priority update.

In this sort of situation it's possible for the ref to end up out of sync with the actual state of the rendered component, if it's updated in a render that gets cancelled.

This has been hypothetical for a long time, but it's just been announced that some of the Concurrent Mode changes will land in React 18, in an opt-in sort of way, with the startTransition API. (And maybe some others)


Realistically, how much this is a practical concern? It's hard to say. startTransition is opt-in so if you don't use it you're probably safe. And many ref updates are going to be fairly 'safe' anyway.

But it may be best to err on the side of caution, if you can.


UPDATE: Now, the react.dev docs also say you should not do it:

Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.

By initialization above they mean such pattern:

function Video() {
  const playerRef = useRef(null);
  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }
  ....
3
On

To the best of my knowledge, it is safe, but you just need to be aware that changes to the ref-boxed value may occur when React "feels like" rendering your component and not necessarily deterministically.

This looks a lot like react-use's useLatest hook (docs), reproduced here since it's trivial:

import { useRef } from 'react';

const useLatest = <T>(value: T): { readonly current: T } => {
  const ref = useRef(value);
  ref.current = value;
  return ref;
};

export default useLatest;

If it works for react-use, I think it's fine for you too.

5
On
function MyComponent({ value }) {
  const valueRef = useRef(value);
  valueRef.current = value;  // is this ok?

  const onClick = useCallback(() => {
    console.log("the latest value is", valueRef.current);
  }, []);

  ...
}

I don't really see an issue here as valueRef.current = value will occur every render cycle. It's not expensive, but it will happen every render cycle.

If you use an useEffect hook then you at least minify the number of times you set the ref value to only when the prop actually changes.

function MyComponent({ value }) {
  const valueRef = useRef(value);
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  const onClick = useCallback(() => {
    console.log("the latest value is", valueRef.current);
  }, []);

  ...
}

Because of the way useEffect works with the component lifecycle I'd recommend sticking to using the useEffect hook and keeping normal React patterns. Using the useEffect hook also provides a more deterministic value per real render cycle, i.e. the "commit phase" versus the "render phase" that can be cancelled, aborted, recalled, etc...

Curious though, if you just want the latest value prop value, just reference the value prop directly, it will always be the current latest value. Add it to the useCallback hook's dependency. This is essentially what you are accomplishing with the useEffect to update the ref, but in a clearer manner.

function MyComponent({ value }) {
  ...

  const onClick = useCallback(() => {
    console.log("the latest value is", value);
  }, [value]);

  ...
}

If you really just always want the latest mutated value then yeah, skip the useCallback dependencies, and skip the useEffect, and mutate the ref all you want/need and just reference whatever the current ref value is at the time the callback is invoked.