October 28, 2019

useRef vs useState


Note: this post was updated on 10/20/2020, adding the TL;DR and editing for clarity.

TL;DR Use useRef when you need information that is available regardless of component lifecycle and whose changes should NOT trigger rerenders. Use useState for information whose changes should trigger rerenders.

When should you use useRef vs useState? The classic example to demonstrate the difference is how to keep track of whether a component is mounted when using hooks.

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.

Take this example: 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>
  );
}

This example demonstrates what can happen if you try to call setState on an unmounted component - if you click the increment button and then really quickly click the hide button, you’ll 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. (though only in development mode). 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 get the error!

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 - we were still looking at the old value. 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.