71

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.

trusktr
  • 44,284
  • 53
  • 191
  • 263
  • In some cases you can get away with just `useLayoutEffect` to make sure that the ref is not null. – grabantot Dec 12 '22 at 08:13
  • @grabantot 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! – trusktr Dec 24 '22 at 00:39

2 Answers2

106

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>
ford04
  • 66,267
  • 20
  • 199
  • 171
  • Thanks. The ref feature in React is not ideal. Refs are much easier in Vue, for example. – trusktr Feb 05 '20 at 02:15
  • 1
    @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`. – ford04 Dec 05 '20 at 18:13
  • 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? – Eliran Malka Dec 21 '21 at 20:20
  • 1
    Great crisp answer! – Bhargav Shah Jan 03 '22 at 10:42
  • 2
    This was the first time I've seen useCallback in an example that actually made sense to me. Thank you! – chromaloop Jan 31 '22 at 20:28
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>
Tholle
  • 108,070
  • 19
  • 198
  • 189
  • So in the class-based example, when would I do that check? – trusktr Apr 25 '19 at 01:05
  • 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)? – trusktr Apr 25 '19 at 01:08
  • 1
    @trusktr 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. – Tholle Apr 25 '19 at 07:42
  • 2
    "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. – trusktr Apr 26 '19 at 21:23
  • 1
    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). – trusktr Apr 26 '19 at 21:23