React: Accessing state/props in callback ref with hooks

Created on 18 Jul 2019  Â·  13Comments  Â·  Source: facebook/react

Do you want to request a feature or report a bug?

Requesting guidance on how to implement something with hooks that used to be possible with class components (so it's not quite a bug, but it might end up becoming a feature request)

What is the current behavior?

If you want to access an external value inside a callback ref (props/state/etc.), you can use useCallback. However, in some cases you may want to avoid re-triggering the callback ref when one of those external values change. In that case, you could use useRef to work around this, and update the ref's value with useEffect/useLayoutEffect, like this:

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

// Now the callback ref can use `valueRef.current`

However, callback refs are called before useEffect/useLayoutEffect callbacks, so if the callback ref happens to be triggered as a result of the same render in which value changes, it will see the old value when it access valueRef.current.

This could be worked around by updating valueRef as a side effect of render:

const valueRef = useRef(value);
valueRef.current = value;

This is admittedly a very specific edge case, but I have actually introduced bugs into real code due to this, when trying to convert existing class components to use hooks. Another motivation for this is to use it to help implement a custom hook for making callback refs nicer to use by mimicking the useEffect API: https://github.com/facebook/react/issues/15176#issuecomment-512185852

Interestingly, this issue does not happen with class components, because this.props and this.state have the correct value when the callback ref is triggered.

Here is a codesandbox with a contrived example that reproduces the issue (and shows how the issue does not occur with a class component): https://codesandbox.io/s/callback-refhooks-72m3p

What is the expected behavior?

That there is some way of handling this use case using hooks, and in such a way that works with concurrent mode:

• Wanting to access external values in a callback ref
• Wanting to avoid triggering the callback ref when those values change

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?

  • React/React-DOM 16.8.6
  • Not a browser/OS-related issue
  • Also did not work in previous versions of React

Most helpful comment

https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

This should help.

Are you referring to the pattern of using a ref to reference the value, and updating the ref's value in a useEffect callback?

Unfortunately this doesn't work because useEffect callbacks are triggered after callback refs, so the callback ref will see the old value in some cases (i.e. if the callback ref gets re-triggered as a result of the same render in which the value changes).

All 13 comments

I have posted a similar question a couple days ago: #16091

I think this is what you need: (answer from @butchler)

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

The idea is to mutate the ref on effect hooks, not during render which will cause potential bugs on concurrent mode.

@dangcuuson That is what I would normally do, but unfortunately that doesn't work in this case because callback refs get called by React before useEffect and useLayoutEffect. If the value is changed in the same render that causes the callback ref to be called, and if the callback ref accesses the value immediately upon being called, it will see the old value.

Given all these conditions, I think it's fair to say this is an edge case that won't affect most people, but it's an edge case that has bitten me more than once and I would like to know a concurrent-mode-safe way to handle it. 🙂

The useLatest thing is my comment, by the way 😛

Oops, that's embarrassing... :smile:

I had a look at your CodeSandbox, it's a rare case indeed.

What I could think of is that you'd need to explicitly update the ref whenever you update your state. e.g before calling setValue, also update valueRef.current.
A generic solution (for state) is like this:

const useStateWithRef = (initialState) => {
  const [state, _setState] = React.useState(initialState);
  const ref = React.useRef(state);
  const setState = React.useCallback(
    (newState) => {
      if (typeof newState === 'function') {
        _setState(prevState => {
          const computedState = newState(prevState);
          ref.current = computedState;
          return computedState;
        });
      } else {
        ref.current = newState;
        _setState(newState);
      }
    },
    []
  );
  return [state, setState, ref];
}

CodeSandbox

Anyway, it looks like there's inconsistencies in the hook order between Class & Function Compnents

Anyway, it looks like there's inconsistencies in the hook order between Class & Function Compnents

If you consider componentDidUpdate to be equivalent to useLayoutEffect, then the order is actually consistent: componentDidUpdate also gets triggered after callback refs.

I think the difference is that class components also manage the state of this.props and this.state for you, whereas when using hooks these things are just normal variables in closures, which changes some things.

I think that not recomputing the callback when the value changes smells funny to me. I would really ask the question of whether this is necessary (or even desired) for your particular use case.

The reason why this may cause issues in concurrent mode is in the case when a particular render is triggered (updating valueRef.current), then suspended and a higher priority render occurs (updating valueRef.current again), commits, and then the first render re-computed and committed.

My hypothesis, however, is that this is only an issue if your operation during render is not idempotent.

For example, I think this will be fine:

let [value, setValue] = useState(0);
let valueRef = useRef(value);

valueRef.current = value;

Because valueRef.current is always computed only from values in the current render.

However, if you had an operation like:

let [value, setValue] = useState(0);
let valueRef = useRef(value);

valueRef.current = valueRef.current + value; // use the previous ref value

This would cause issues because other renders may have aborted before committing, but still updated the valueRef, causing the current valueRef to not be continuous based on the order of renders.

Again, this is a hypothesis that I can't be absolutely sure of so will wait for someone to correct me. And again, I would _really_ question why this was needed and would want to walk through all the other possible solutions and tradeoffs with a co-worker before I committed it to a project.

I think that not recomputing the callback when the value changes smells funny to me. I would really ask the question of whether this is necessary (or even desired) for your particular use case.

I completely agree. For a practical solution to this, I would just use useCallback to create a new callback ref when the value changes.

This gets a little bit inconvenient if you want to wrap callback refs with a useEffect-style API, but it's still not impossible.

While I haven't run into a clear use case for it yet, it's theoretically possible that there might be some use case where you only want the callback ref to be called when the element mounts/re-mounts, and not when one of the values it uses change.

Maybe I should close this issue until someone finds a clear use case like this?

https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

This should help.

https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

This should help.

Are you referring to the pattern of using a ref to reference the value, and updating the ref's value in a useEffect callback?

Unfortunately this doesn't work because useEffect callbacks are triggered after callback refs, so the callback ref will see the old value in some cases (i.e. if the callback ref gets re-triggered as a result of the same render in which the value changes).

Could... could somebody re-open this please? I know it's an edge case, but it is still a real issue that people can run into, especially if they try to blindly apply the approach recommended in the docs linked to above when defining callback refs.

A real use-case: I'm wrapping MapboxGL, which is initialized with an element to render into as well as a style prop describing what to render. This style prop changes often and must be defined on initialization of the map instance. Extracted example:

// this is the problematic value
const { style } = props;

// keep reference to mapboxgl map instance
const mapRef = useRef<mapboxgl.Map>();

// create/destroy mapboxgl map instance on node mount/unmount
const callbackRef = useCallback((container: HTMLElement | null) => {
  if (container) {
    mapRef.current = new mapboxgl.Map({ container, style });
  } else if (mapRef.current) {
    mapRef.current.remove();
    mapRef.current = undefined;
  }
}, []);

// use mapboxgl style diffing on style change
useEffect(() => {
  if (!mapRef.current) return;
  mapRef.current.setStyle(style);
}, [style]);

return <div ref={callbackRef} />;
  • adding style to the dependency array causes the map instance to be destroyed and recreated on every style change
  • using useRef and useEffect (like useLatest mentioned above) causes style to be undefined when the callback ref is invoked on mount

What does work is assigning to a ref during render:

const styleRef = useRef();
styleRef.current = style;

That is, without wrapping mutation of the ref in useEffect.

EDIT: Welp, nevermind. I simply had to initialize the ref (which is done in useLatest mentioned above, but not for my previous bulletpoint):

const styleRef = useRef(style);
useEffect(() => {
  styleRef.current = style;
}, [style]);

That solves my use-case.

@zachasme Your example has all the setup necessary to exhibit the issue, but is missing one last step: for it to be possible for the element to be unmounted/remounted (which would re-trigger the callback ref). The easiest way to do this would be add a key to the element.

In the current example, the callback ref uses useCallback with an empty dependency array. This means that if it got re-triggered (e.g. due to a key changing), it would use the original value of style from the first render, which is incorrect.

You could use something like useLatest to allow the callback ref to have access to the last committed value of the style prop. However, if the value of style and the value of the key happen to both change as part of the same render, then the callback ref will see the previous value of style when it gets called (because the useLatest approach updates the value inside useEffect but useEffect is triggered after callback refs). Again: this is a really unlikely edge case, but it can happen.

It seems like the only approach left is to add style as a dependency for the callback ref's useCallback, but that is what we were trying to avoid in the first place to avoid the callback ref getting triggered unnecessarily and generating new state/side effects.

I guess the recipe to reproduce the issue is "callback ref + useCallback + key + getting unlucky" 😛

The simplest solution I can think of to this rare edge case would be to have access to some version of useEffect that has higher priority than callback refs (either by changing useLayoutEffect to have higher priority, or by introducing a new hook/new option to an existing hook).

There is one kind of hacky solution: the element reference could be stored as state (which would be updated via a callback ref), and the actual side effect that would normally be inside the callback ref would instead go inside a useEffect or useLayoutEffect (using the element from state as the dependency). Using state to store a reference to an element feels like an anti-pattern when we already have refs for exactly that purpose, but maybe it's fine in some cases?

So far I haven't been able to think of any other way that this could be solved without making a change to React itself.

Was this page helpful?
0 / 5 - 0 ratings