October 28, 2019
tech  react  hooks  explainer 

useRef vs useState

How should you keep track of whether a component is mounted when using hooks? I got pretty confused about this, and the answer clarified by mental model of React hooks and I wanted to share.

In a class component, a classic way to do it is to keep an isMounted flag on the component instance, and update it in componentDidMount and componentWillUnmount. However, those methods aren’t available to us when using hooks.

Let’s use an example that will help us demonstrate. We have two buttons, one that increments a counter at a delay, and another that shows or hides the UI. The normal behavior is that you can click the counter a bunch of times, and when you’re done, you can hide the UI.

Demo

Codepen

function Counter() {
  const [count, setCount] = useState(0);
  const increment = () => {
    setTimeout(() => {
      setCount(currentCount => setCount(currentCount + 1));
    }, 1500);
  }
  return (
    <div>
      Count: {count}
      <button onClick={increment}>Increment</button>
    </div>
  );
}

function Wrapper() {
  const [show, setShow] = useState(true);
  const toggleShow = () => {
    setShow(currentShow => setShow(!currentShow));
  }

  return (
    <div>
      {show && <Counter />}
      <button onClick={toggleShow}>{show ? 'Hide' : 'Show'}</button>
    </div>
  );
}

But, what happens if you click the increment button and then really quickly click the hide button? Depending on how fast we click, in development mode we might get the error Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the undefined component. We want to make sure we don’t try to set state after the component has been unmounted.

To fix this, we need to keep track of whether the component is mounted or not. Then, in our increment callback, we should be able to early return if the component isn’t mounted anymore. You might think you could write the component like this:

Codepen

function Counter() {
  const [count, setCount] = useState(0);
  const [isMounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    return () => {
      setMounted(false);
    };
  });

  const increment = () => {
    setTimeout(() => {
      if (isMounted) {
        console.log('Mounted, going ahead with setting count!')
        setCount(currentCount => setCount(currentCount + 1));
      }
    }, 1500);
  };

  return (
    <div>
      Count: {count}
      <button onClick={increment}>Increment</button>
    </div>
  );
}

However, this won’t work - even after we clearly have unmounted the component, we still log!

Demo with useState

This is because our callback in setTimeout is reading from stale state - isMounted was true when we wrote the function and we maintained access to that value through a closure. But, when we called setMounted(false), isMounted wasn’t updated from the POV of our callback. Instead, we need a way to get the current value of isMounted, even it’s changed since we invoked setTimeout.

It turns out, that’s what refs are great for! From the React docs:

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

That’s exactly what we want. So, we can rewrite our logic like this:

Codepen

function Counter() {
  const [count, setCount] = useState(0);
  const isMounted = useRef(false);

  useEffect(() => {
    isMounted.current = true;
    return () => {
      isMounted.current = false;
    };
  });

  const increment = () => {
    setTimeout(() => {
      if (isMounted.current) {
        console.log('Mounted, going ahead with setting count!')
        setCount(currentCount => setCount(currentCount + 1));
      }
    }, 1500);
  };

  return (
    <div>
      Count: {count}
      <button onClick={increment}>Increment</button>
    </div>
  );
}

Now, if we increment a bunch and then hide the UI, we don’t log anymore!

Demo with useRef

This is because we’re using the isMounted ref and reading from its current value at the time we want to make a decision. And this makes sense! useState should be used when the value of state should affect what gets rendered. useRef should be used when you want to have a piece of information that persists “for the full lifetime of the component” - not just during its render cycle.