react icon indicating copy to clipboard operation
react copied to clipboard

React 18 let's make ref.currant to be reactive value

Open MaxmaxmaximusAWS opened this issue 2 years ago • 11 comments

Let's add this hook as part of the core. Since this is a common need, many people often ask the question "Why does useEffect not sense ref.current changes?"

// approximate implementation
const useReactiveRef = (defaultValue) => {
  const [current, ref] = useState(defaultValue)
  ref.current = current
  return ref
}

Usage example:

const Component = () => {
  const ref = React.useReactiveRef()

  useEffect(() => {
    // ref.current now is reactive
    console.log(ref.current)
  }, [ref.current])

  return <div ref={ref}></div>
}

MaxmaxmaximusAWS avatar Jul 17 '21 14:07 MaxmaxmaximusAWS

Making refs "reactive" as you say would essentially make them the same as the useState hook. If you want a value that ensures a re-render on change, use the state hook. Can you explain why that would not be sufficient?


Edit To be clear, the issue is not that a change in ref value won't re-run an effect. It's that a change in ref value won't re-render a component. (If something else happens to re-render the component, and a ref value is passed in as a dependency– the effect would re-run. The reason we advise against using refs in the dependencies array is that changes to refs don't cause a component to re-render in the first place, as dependencies coming from state do.)

bvaughn avatar Jul 18 '21 15:07 bvaughn

Can you explain why that would not be sufficient?

useCallback is sugar on top of useMemo, but why did we add useCallback when there is already useMemo? Because useCallback is a common use case. useState is sugar on top of useReducer, but we anyway added useState. People often mistakenly try to specify ref.current as dependencies of other hooks. And they often make a mistake, because ref.current is not a reactive value. To make ref.current reactive, people would have to write the same hook many times, which facebook could implement and insert into the core. The implementation I wrote is only approximate. If facebook can make a better implementation that somehow won't cause the component to re-render, then great.

refs serve for imperative interaction with elements. useEffect also serves for imperative interaction. Obviously, useEffect will want to use ref.current as a dependency, this is a common use case

MaxmaxmaximusAWS avatar Jul 20 '21 11:07 MaxmaxmaximusAWS

Can you share some examples of how you'd use it? Meaning concrete examples where you want .current to be a dependency. I'd like to better understand the scenarios you're describing.

gaearon avatar Jul 20 '21 11:07 gaearon

@gaearon Effects have a handy "effect undo" ability

  const ref = useRef(null)
  
  useEffect(()=> { 
    const observer = new ResizeObserver()
    observer.observe(ref.current)
    
    return () => {
      observer.disconnect()
    }

  }, [ref.current])

  return <div ref={ref}></div>

If we just used a functional ref, then we would have to store the previous ResizeObserver somewhere in order to destroy it later

  const callbackRef = useCallback((element) => {

    //////////////////////////////////////////
    // where to store previoutObserver?
    if(previoutObserver) {
      previoutObserver.disconnect()
    }

    const observer = new ResizeObserver()
    observer.observe(element)
  }, [])

  return <div ref={callbackRef}></div>

Maybe it is worth adding functionality in which functional refs would also return a cancellation function that will be executed when an element is changed or unmounted? this would achieve the same behavior as useEffect, but without re-rendering the component

MaxmaxmaximusAWS avatar Jul 20 '21 12:07 MaxmaxmaximusAWS

The example above already works without anything in the dependencies array:

const ref = useRef(null);

useEffect(() => {
  const observer = new ResizeObserver();
  observer.observe(ref.current);

  return () => {
    observer.disconnect();
  };
}, []);

return <div ref={ref}></div>;

Since <div> isn't conditionally rendered, and the effect runs whenever the component is mounted/shown, then the two already align. If <div> was conditionally rendered, then the prop or state that determined the render could be used instead. For example:

function Example({ showDiv }) {
  const ref = useRef(null);

  useEffect(() => {
    if (showDiv) {
      const observer = new ResizeObserver();
      observer.observe(ref.current);

      return () => {
        observer.disconnect();
      };
    }
  }, [showDiv]);

  return showDiv ? <div ref={ref}></div> : null;
}

Could you maybe provide another example that doesn't work with the current useRef API?

bvaughn avatar Jul 20 '21 13:07 bvaughn

@bvaughn

const ref = useRef(null);

useEffect(() => {
  const observer = new ResizeObserver();
  observer.observe(ref.current);

  return () => {
    observer.disconnect();
  };
}, [ref.current]);

// we don't know when child will change ref.current and how it will use it, but we want
return <Child prop={ref}/>;

My new idea is to give functional refs the ability to return a function that will be executed when ref changes, Similar to how useEffect has it. I understand that there is no need to overload the core api with optional hooks, but this functionality does not add new hooks like useReactiveRef, and in fact is backward compatible. If someone accidentally returned a function before, and does not expect it to be called, we honestly up a major version React to 18, to point out possible compatibility issues. We need to be able to store intermediate data somewhere between calls functional ref, and this is a closure.

const funcRef= useCallback((element) => {
  const observer = new ResizeObserver();
  observer.observe(element);

  // this function will called on ref change,
  // to undo actions made during the previous call, 
  // and potentially necessary variables will be in the closure
  return () => {
    observer.disconnect();
  };
}, []);

return <div>
  { state && <div ref={funcRef}></div> }
</div>

MaxmaxmaximusAWS avatar Jul 21 '21 05:07 MaxmaxmaximusAWS

Thanks for explaining the use case. This is a known inconvenience. Internally we’ve seen people make a custom useEffectRef Hook that does what you suggest.

The current built-in canonical solution to this is callback refs, but it’s a bit awkward that callback refs have a different API from effects.

gaearon avatar Jul 21 '21 12:07 gaearon

Just to spell it out a little more explicitly, one way to approach this using a callback ref would be:

const cleanupRef = useRef(null);
const refSetterFunction = useCallback((element) => {
  if (cleanupRef.current !== null) {
    // Either the <div> has been hidden, or a value in the dependencies array has changed.
    // Either way, this is the time to cleanup.
    cleanupRef.current();
    cleanupRef.current = null;
  }

  if (element !== null) {
    // Either the <div> has been shown, or a value in the dependencies array has changed.
    // Either way, this is the time to recreate our effect.
    const observer = new ResizeObserver();
    observer.observe(element);

    // Store for later cleanup (when <div> is hidden or dependencies change)
    cleanupRef.current = () => {
      observer.disconnect();
    };
  }
}, []);

Dependencies aren't used in the example callback, but they could be added if needed.

bvaughn avatar Jul 21 '21 13:07 bvaughn

@bvaughn Why don't we increase the level of abstraction and convenience, and not just use a closure to store the variables needed for cleaning, This is my suggestion =)

Compare this:

const cleanupRef = useRef(null);
const refSetterFunction = useCallback((element) => {
  if (cleanupRef.current !== null) {
    // Either the <div> has been hidden, or a value in the dependencies array has changed.
    // Either way, this is the time to cleanup.
    cleanupRef.current();
    cleanupRef.current = null;
  }

  if (element !== null) {
    // Either the <div> has been shown, or a value in the dependencies array has changed.
    // Either way, this is the time to recreate our effect.
    const observer = new ResizeObserver();
    observer.observe(element);

    // Store for later cleanup (when <div> is hidden or dependencies change)
    cleanupRef.current = () => {
      observer.disconnect();
    };
  }
}, []);

vs this:

const refSetterFunction = useCallback((element) => {
  if(!element) return

  const observer = new ResizeObserver()
  observer.observe(element)

  return () => {
      observer.disconnect()
  };
}, []);

Is there a person on this earth who will say that the first variant is better? =)

@gaearon make a custom useEffectRef Hook

Yes, I mean the same. If people create something often, and do it in different ways, and perhaps not correctly and not optimally, this is a sign that this functionality needs to be added to the core, not to the user space. do you agree?

I believe that version 18 of react is just right for such an update, let functional refs have the ability to return a "cancellation function", this will cause a minimal loss of backward compatibility, since few people "accidentally" returned a function before, but it will also benefit the general consistency of the api useEffect and callback refs, and give the React api a consistent style, here's my opinion

I cannot influence react directly, but I would like to provide at least some help, feedback, from the side of the react user community. that's why I'm writing all this, especially since the 18th version has not yet been released and there is time to have time to implement this trifle

hope the React team agrees it =)

MaxmaxmaximusAWS avatar Jul 21 '21 21:07 MaxmaxmaximusAWS

Related to #15176 and reactjs/rfcs#205

KurtGokhan avatar Dec 16 '21 15:12 KurtGokhan

想要ref.current能触发render?这和react设计的理念背道而驰啊。

heyuuuu avatar Jul 26 '22 07:07 heyuuuu

@heyuuuu Перевыполнение функции компонента не вызывает рендеринг, у вас плохое представление об устройстве React (а еще, желательно писать на языке мира - на английском языке)

MaxmaxmaximusAWS avatar Jul 27 '22 10:07 MaxmaxmaximusAWS

I've wondering about this for a very long time. It'd be super helpful if callbacks refs could return a cleanup function.

lgenzelis avatar May 18 '23 20:05 lgenzelis