How do we know when a React ref.current value has changed?
Asked Answered
D

2

78

Normally, with props, we can write

componentDidUpdate(oldProps) {
  if (oldProps.foo !== this.props.foo) {
    console.log('foo prop changed')
  }
}

in order to detect prop changes.

But if we use React.createRef(), how to we detect when a ref has changed to a new component or DOM element? The React docs don't really mention anything.

F.e.,

class Foo extends React.Component {
  someRef = React.createRef()

  componentDidUpdate(oldProps) {
    const refChanged = /* What do we put here? */

    if (refChanged) {
      console.log('new ref value:', this.someRef.current)
    }
  }

  render() {
    // ...
  }
}

Are we supposed to implement some sort of old-value thing ourselves?

F.e.,

class Foo extends React.Component {
  someRef = React.createRef()
  oldRef = {}

  componentDidMount() {
    this.oldRef.current = this.someRef.current
  }

  componentDidUpdate(oldProps) {
    const refChanged = this.oldRef.current !== this.someRef.current

    if (refChanged) {
      console.log('new ref value:', this.someRef.current)

      this.oldRef.current = this.someRef.current
    }
  }

  render() {
    // ...
  }
}

Is that what we're supposed to do? I would've thought that React would've baked in some sort of easy feature for this.

Dietitian answered 24/4, 2019 at 20:49 Comment(2)
In some cases you can get away with just useLayoutEffect to make sure that the ref is not null.Marmoreal
@Marmoreal I see, useLayoutEffect after React has updated DOM, and so any refs must have been changed at that point. Good tip. I think that's worthy of being its own answer!Dietitian
M
117

React docs recommend using callback refs to detect ref value changes.

Hooks

export function Comp() {
  const onRefChange = useCallback(node => {
    if (node === null) { 
      // DOM node referenced by ref has been unmounted
    } else {
      // DOM node referenced by ref has changed and exists
    }
  }, []); // adjust deps

  return <h1 ref={onRefChange}>Hey</h1>;
}

useCallback is used to prevent double calling of ref callback with null and the element.

You can trigger re-renders on change by storing the current DOM node with useState:

const [domNode, setDomNode] = useState(null);
const onRefChange = useCallback(node => {
  setDomNode(node); // trigger re-render on changes
  // ...
}, []);

Class component

export class FooClass extends React.Component {
  state = { ref: null, ... };

  onRefChange = node => {
    // same as Hooks example, re-render on changes
    this.setState({ ref: node });
  };

  render() {
    return <h1 ref={this.onRefChange}>Hey</h1>;
  }
}

Note: useRef doesn't notify of ref changes. Also no luck with React.createRef() / object refs.

Here is a test case, that drops and re-adds a node while triggering onRefChange callback :

const Foo = () => {
  const [ref, setRef] = useState(null);
  const [removed, remove] = useState(false);

  useEffect(() => {
    setTimeout(() => remove(true), 3000); // drop after 3 sec
    setTimeout(() => remove(false), 5000); // ... and mount it again
  }, []);

  const onRefChange = useCallback(node => {
    console.log("ref changed to:", node);
    setRef(node); // or change other state to re-render
  }, []);

  return !removed && <h3 ref={onRefChange}>Hello, world</h3>;
}

ReactDOM.render(<Foo />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.1/umd/react.production.min.js" integrity="sha256-vMEjoeSlzpWvres5mDlxmSKxx6jAmDNY4zCt712YCI0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.1/umd/react-dom.production.min.js" integrity="sha256-QQt6MpTdAD0DiPLhqhzVyPs1flIdstR4/R7x4GqCvZ4=" crossorigin="anonymous"></script>

<script> var {useState, useEffect, useCallback} = React</script>

<div id="root"></div>
Matteson answered 4/2, 2020 at 22:12 Comment(5)
Thanks. The ref feature in React is not ideal. Refs are much easier in Vue, for example.Dietitian
@Erwol yes, you can do that. If you need to re-render, when the node changes, go with useState/setState. If a node change shouldn't trigger a re-render, use a ref or just an instance variable (in case of classes). If going with refs, you normally would rather write something like this.containerRef.current = currentNode.Matteson
how about ref forwarding? i'm thinking we can possibly still use React.createRef() references, if we accept the ref from outside the component (Comp(props, ref), etc.). assuming the ref is refreshed on every render; could something like that work?Ming
This was the first time I've seen useCallback in an example that actually made sense to me. Thank you!Lusatian
If using typescript it will look something like const onRefChange = useCallback((node: HTMLElement | null) => { ... }, []).Knisley
P
3

componentDidUpdate is invoked when the component state or props change, so it will not necessarily be invoked when a ref changes since it can be mutated as you see fit.

If you want to check if a ref has changed from previous render though, you can keep another ref that you check against the real one.

Example

class App extends React.Component {
  prevRef = null;
  ref = React.createRef();
  state = {
    isVisible: true
  };

  componentDidMount() {
    this.prevRef = this.ref.current;

    setTimeout(() => {
      this.setState({ isVisible: false });
    }, 1000);
  }

  componentDidUpdate() {
    if (this.prevRef !== this.ref.current) {
      console.log("ref changed!");
    }

    this.prevRef = this.ref.current;
  }

  render() {
    return this.state.isVisible ? <div ref={this.ref}>Foo</div> : null;
  }
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

<div id="root"></div>
Polygyny answered 24/4, 2019 at 21:15 Comment(5)
So in the class-based example, when would I do that check?Dietitian
Wait, isn't componentDidUpdate called after every render? So then, isn't componentDidUpdate the place to do the check (even if componentDidUpdate was triggered indirectly by prop or state changes)?Dietitian
@Dietitian Yes, you're right that componentDidUpdate is called indirectly after a prop or state change, but a ref is a mutable value that can be changed by anything, and React has no way of knowing that the ref changed in that sense. In the class example you would use a combination of componentDidMount and componentDidUpdate. I updated the answer.Polygyny
"a ref is a mutable value that can be changed by anything", true, but similarly so can anything in this.state, however we obviously avoid doing that because it's not the way to change state. Likewise, I think it'd be (hopefully) obvious that we shouldn't arbitrarily modify props or refs. So, it seems that if we let only React modify ref.current (only by passing the ref into the JSX markup), then our idea of having to track the old value seems to be the only way to do it. It'd be nice if React had more in place around this.Dietitian
With the old refs (function based refs), it was easy to just setState with the new ref inside the functions, which would trigger reactivity without having to track old values manually. In hind-sight, this might've been more intuitive (as in more obvious how to handle reactivity). (However, I hate that every call of the function would ALWAYS begin with a null ref, which was absolutely mind-boggling. They reasoned that it was in order to force cleanup, but I think it caused more problems than guarding against bad end-user code).Dietitian

© 2022 - 2024 — McMap. All rights reserved.