Is it safe to use a useState "setter" function as a callback ref?
Asked Answered
Z

2

13

Is it safe to use the setter function of a useState hook as a callback ref function? Will this cause trouble with Suspense or other upcoming React changes? If "yes, this is OK", that's cool! If "no" why not? If "maybe" then when is it OK vs. not?

I'm asking because one of my components requires three refs to be mounted before it can call a DOM API. Two of those required refs are "normal" refs assigned in the same component via a JSX ref prop. The other ref will be assigned, via React context, in a deeply-nested component at some later time. I needed a way to force a re-render of the parent component after all three refs were mounted, and to force a useEffect cleanup when any of the refs are unmounted.

Originally I wrote my own callback ref handler which called a useState setter that I stored in a context provider. But then I realized that the useState setter did everything that my own callback ref did. Is it safe to just use the setter instead of writing my own callback ref function? Or is there a better and/or safer way to do what I'm trying to do?

I tried Googling for "useState" "callback ref" (and other similar keyword variations) but results weren't helpful, other than @theKashey's excellent use-callback-ref package which I will definitely use elsewhere (e.g. when I need to pass a callback ref to a component that expects a RefObject, or when I need both a callback and to use a ref locally) but in this case all the callback needs to do is set a state variable when the ref changes, so Anton's package seems like overkill here.

A simplified example is below and at https://codesandbox.io/s/dreamy-shockley-5dc74.

import * as React from 'react';
import { useState, forwardRef, useEffect, createContext, useContext, useMemo } from 'react';
import { render } from 'react-dom';

const Child = forwardRef((props, ref) => {
  return <div ref={ref}>This is a regular child component</div>;
});

const refContext = createContext();
const ContextUsingChild = props => {
  const { setValue } = useContext(refContext);
  return <div ref={setValue}>This child uses context</div>;
};

const Parent = () => {
  const [child1, setChild1] = useState(null);
  const [child2, setChild2] = useState(null);
  const [child3, setChild3] = useState(null);

  useEffect(() => {
    if (child1 && child2) {
      console.log(`Child 1 text: ${child1.innerText}`);
      console.log(`Child 2 text: ${child2.innerText}`);
      console.log(`Child 3 text: ${child3.innerText}`);
    } else {
      console.log(`Child 1: ${child1 ? '' : 'not '}mounted`);
      console.log(`Child 2: ${child2 ? '' : 'not '}mounted`);
      console.log(`Child 3: ${child3 ? '' : 'not '}mounted`);
      console.log(`In a real app, would run a cleanup function here`);
    }
  }, [child1, child2, child3]);

  const value = useMemo(() => ({ setValue: setChild3 }), []);

  return (
    <refContext.Provider value={value}>
      <div className="App">
        This is text in the parent component
        <Child ref={setChild1} />
        <Child ref={setChild2} />
        <ContextUsingChild />
      </div>
    </refContext.Provider>
  );
};

const rootElement = document.getElementById('root');
render(<Parent />, rootElement);
Zettazeugma answered 9/12, 2019 at 7:3 Comment(2)
Not sure if you already got your answer. I'm currently also wondering the same thing. Libraries like react-popper use useState setters as callback refs as well... popper.js.org/react-popper/v2/#examplePublishing
I have noticed a difference between ref={setElement} and ref={element => setElement(element} in my app... The former misses updates on occasion... I have no clue whyGoodrow
G
0

useState "setter" functions maintain the same reference over render cycles, so those should be safe to use. It is even mentioned that they can be omitted on dependency arrays:

(The identity of the setCount function is guaranteed to be stable so it’s safe to omit.)

You could pass the setter function directly:

<refContext.Provider value={setChild3}>

...and then read it:

const ContextUsingChild = props => {
  const setChild3 = useContext(refContext);
  return <div ref={setChild3}>This child uses context</div>;
};
Gnarl answered 12/3, 2021 at 3:59 Comment(0)
S
0

You will have extra re-render each time target element appears(since it might be conditionally rendered it may happen more than once-in-lifetime).

Otherwise it should work just fine. Except, it might be confusing to future code readers.

I'd rather use extra function + useRef to make less cognitive load(aka "confusion"). And even more: we might not need callback ref since we have useEffect:

const childRef = useRef();

useEffect(() => {
 ... do something
}, [childRef.current]);

<div ref={childRef} ...

But sure, in very general case this might not suit us, since callback ref is called just twice per target element's life, while useEffect might be triggered more often if there are more dependencies in the list. But still.

Semen answered 15/8, 2021 at 19:49 Comment(2)
Refs can't be used as dependencies of useEffect or other hooks. They aren't "reactive", which means nothing reacts to their changes.Manara
@Manara technically - yes. However, until you mutate your DOM directly(what we should avoid when using React), ref.current becomes different only after re-rendering. And React checks dependencies for useEffect after each re-render. So you can use ref.current as a dependency. As well as you can use something like Math.random(). Probably, true reactive system like signal would not trigger related effect, but React's useEffect is simpler by its logic.Semen

© 2022 - 2024 — McMap. All rights reserved.