Implementing advanced usePrevious hook with React useRef

Nadia Makarevich - Mar 21 '22 - - Dev Community

Image description

Originally published at https://www.developerway.com. The website has more articles like this 😉


After Context, ref is probably the most mysterious part of React. We almost got used to the ref attribute on our components, but not everyone is aware, that its usage is not limited to passing it back and forth between components and attaching it to the DOM nodes. We actually can store data there! And even implement things like usePrevious hook to get the previous state or props or any other value.

By the way, if you ever used that hook in the way that is written in React docs, have you investigated how it actually works? And what value it returns and why? The result might surprise you 😉

So this is exactly what I want to do in this article: take a look at ref and how it works when it’s not attached to a DOM node; investigate how usePrevious works and show why it’s not always a good idea to use it as-is; implement a more advanced version of the hook as a bonus 🙂

Ready to join in?

First of all, what is ref?

Let’s remember some basics first, to understand it fully.

Imagine you need to store and manipulate some data in a component. Normally, we have two options: either put it in a variable or in the state. In a variable you’d put something that needs to be re-calculated on every re-render, like any intermediate value that depends on a prop value:

const Form = ({ price }) => {
  const discount = 0.1 * price;

  return <>Discount: {discount}</>;
};
Enter fullscreen mode Exit fullscreen mode

Creating a new variable or changing that variable won’t cause Form component to re-render.

In the state, we usually put values that need to be saved between re-renders, typically coming from users interacting with our UI:

const Form = () => {
  const [name, setName] = useState();

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
};
Enter fullscreen mode Exit fullscreen mode

Changing the state will cause the Form component to re-render itself.

There is, however, a third, lesser-known option: ref. It merges the behaviour of those two: it’s essentially a variable that doesn’t cause components to re-render, but its value is preserved between re-renders.

Let’s just implement a counter (I promise, it’s the first and the last counter example in this blog) to illustrate all those three behaviours.

const Counter = () => {
  let counter = 0;

  const onClick = () => {
    counter = counter + 1;
    console.log(counter);
  };

  return (
    <>
      <button onClick={onClick}>click to update counter</button>
      Counter value: {counter}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is not going to work of course. In our console.log we’ll see the updated counter value, but the value rendered on the screen is not going to change - variables don’t cause re-renders, so our render output will never be updated.

State, on the other hand, will work as expected: that’s exactly what state is for.

const Counter = () => {
  const [counter, setCounter] = useState(0);

  const onClick = () => {
    setCounter(counter + 1);
  };

  return (
    <>
      <button onClick={onClick}>click to update counter</button>
      Counter value: {counter}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now the interesting part: the same with ref.

const Counter = () => {
  // set ref's initial value, same as state
  const ref = useRef(0);

  const onClick = () => {
    // ref.current is where our counter value is stored
    ref.current = ref.current + 1;
  };

  return (
    <>
      <button onClick={onClick}>click to update counter</button>
      Counter value: {ref.curent}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is also not going to work. Almost. With every click on the button the value in the ref changes, but changing ref value doesn’t cause re-render, so the render output again is not updated. But! If something else causes a render cycle after that, render output will be updated with the latest value from the ref.current. For example, if I add both of the counters to the same function:

const Counter = () => {
  const ref = useRef(0);
  const [stateCounter, setStateCounter] = useState(0);

  return (
    <>
      <button onClick={() => setStateCounter(stateCounter + 1)}>update state counter</button>
      <button
        onClick={() => {
          ref.current = ref.current + 1;
        }}
      >
        update ref counter
      </button>
      State counter value: {stateCounter}
      Ref counter value: {ref.curent}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This will lead to an interesting effect: every time you click on the “update ref counter” button nothing visible happens. But if after that you click the “update state counter” button, the render output will be updated with both of the values. Play around with it in the codesandbox.

Counter is obviously not the best use of refs. There is, however, a very interesting use case for them, that is even recommended in React docs themselves: to implement a hook usePrevious that returns previous state or props. Let’s implement it next!

usePrevious hook from React docs

Before jumping into re-inventing the wheel, let’s see what the docs have to offer:

const usePrevious = (value) => {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};
Enter fullscreen mode Exit fullscreen mode

Seems simple enough. Now, before diving into how it actually works, let’s first try it out on a simple form.

We’ll have a settings page, where you need to type in your name and select a price for your future product. And at the bottom of the page, I’ll have a simple “show price change” component, that will show the current selected price, and whether this price increased or decreased compared to the previous value - this is where I’m going to use the usePrevious hook.

Let’s start with implementing the form with price only since it’s the most important part of our functionality.

const prices = [100, 200, 300, 400, 500, 600, 700];

const Page = () => {
  const [price, setPrice] = useState(100);

  const onPriceChange = (e) => setPrice(Number(e.target.value));

  return (
    <>
      <select value={price} onChange={onPriceChange}>
        {prices.map((price) => (<option value={price}>{price}$</option>))}
      </select>
      <Price price={price} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the price component:

export const Price = ({ price }) => {
  const prevPrice = usePrevious(price);
  const icon = prevPrice && prevPrice < price ? '😡' : '😊';

  return (
    <div>
      Current price: {price}; <br />
      Previous price: {prevPrice} {icon}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Works like a charm, thank you React docs! See the codesandbox.

Now the final small step: add the name input field to the form, to complete the functionality.

const Page = () => {
  const [name, setName] = useState("");

  const onNameChange = (e) => setName(e.target.value);

  // the rest of the code is the same

  return (
    <>
      <input type="text" value={name} onChange={onNameChange} />
      <!-- the rest is the same -->
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Works like a charm as well? No! 🙀 When I’m selecting the price, everything works as before. But as soon as I start typing in the name input - the value in the Price component resets itself to the latest selected value, instead of the previous. See the codesandbox.

But why? 🤔

Now it’s time to take a closer look at the implementation of usePrevious, remember how ref behaves, and how React lifecycle and re-renders works.

const usePrevious = (value) => {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};
Enter fullscreen mode Exit fullscreen mode

First, during the initial render of the Price component, we call our usePrevious hook. In there we create ref with an empty value. After that, we immediately return the value of the created ref, which in this case will be null (which is intentional, there isn't a previous value on the initial render). After the initial render finishes, useEffect is triggered, in which we update the ref.current with the value we passed to the hook. And, since it’s a ref, not state, the value just “sits” there mutated, without causing the hook to re-render itself and as a result without its consumer component getting the latest ref value.

If it’s difficult to imagine from the text, here is some visual aid:

Image description

So what happens then when I start typing in the name fields? The parent Form component updates its state → triggers re-renders of its children → Price component starts its re-render → calls usePrevious hook with the same price value (we changed only name) → hook returns the updated value that we mutated during the previous render cycle → render finishes, useEffect is triggered, done. On the pic before we’ll have values 300 transitioning to 300. And that will cause the value rendered in the Price component to be updated.

So what this hook in its current implementation does, is it returns a value from the previous render cycle. There are, of course, use cases for using it that way. Maybe you just need to trigger some data fetch when the value changes, and what happens after multiple re-renders doesn’t really matter. But if you want to show the “previous” value in the UI anywhere, a much more reliable approach here would be for the hook to return the actual previous value.

Let’s implement exactly that.

usePrevious hook to return the actual previous value

In order to do that, we just need to save in ref both values - previous and current. And switch them only when the value actually changes. And here again where ref could come in handy:

export const usePreviousPersistent = (value) => {
  // initialise the ref with previous and current values
  const ref = useRef({
    value: value,
    prev: null,
  });

  const current = ref.current.value;

  // if the value passed into hook doesn't match what we store as "current"
  // move the "current" to the "previous"
  // and store the passed value as "current"
  if (value !== current) {
    ref.current = {
      value: value,
      prev: current,
    };
  }

  // return the previous value only
  return ref.current.prev;
};
Enter fullscreen mode Exit fullscreen mode

Implementation even became slightly simpler: we got rid of the mind-boggling magic of relying on useEffect and just accept a value, do an if statement, and return a value. And no glitches in the UI anymore! Check it out in the codesandbox.

Now, the big question: do we really need refs here? Can’t we just implement exactly the same thing with the state and not resort to escape hatches (which ref actually is)? Well, technically yes, we can, the code will be pretty much the same:

export const usePreviousPersistent = (value) => {
  const [state, setState] = useState({
    value: value,
    prev: null,
  });

  const current = state.value;

  if (value !== current) {
    setState({
      value: value,
      prev: current,
    });
  }

  return state.prev;
};
Enter fullscreen mode Exit fullscreen mode

There is one problem with this: every time the value changes it will trigger state update, which in turn will trigger re-render of the “host” component. This will result in the Price component being re-rendered twice every time the price prop changes - the first time because of the actual prop change, and the second - because of the state update in the hook. Doesn’t really matter for our small form, but as a generic solution that is meant to be used anywhere - not a good idea. See the code here, change the price value to see the double re-render.

usePrevious hook: deal with objects properly

Last polish to the hook left: what will happen if I try to pass an object there? For example all the props?

export const Price = (props) => {
  // with the current implementation only primitive values are supported
  const prevProps = usePreviousPersistent(props);
  ...
};
Enter fullscreen mode Exit fullscreen mode

The glitch, unfortunately, will return: we’re doing the shallow comparison here: (value !== current), so the if check will always return true. To fix this, we can just introduce the deep equality comparison instead.

import isEqual from 'lodash/isEqual';

export const usePreviousPersistent = (value) => {
  ...
  if (!isEqual(value, current)) {
    ...
  }

  return state.prev;
};
Enter fullscreen mode Exit fullscreen mode

Personally, I’m not a huge fan of this solution: on big data sets it can become slow, plus depending on an external library (or implementing deep equality by myself) in a hook like that seems less than optimal.

Another way, since hooks are just functions and can accept any arguments, is to introduce a “matcher” function. Something like this:

export const usePreviousPersistent = (value, isEqualFunc) => {
  ...
  if (isEqualFunc ? !isEqualFunc(value, current) : value !== current) {
    ...
  }

  return state.prev;
};
Enter fullscreen mode Exit fullscreen mode

That way we still can use the hook without the function - it will fallback to the shallow comparison. And also now have the ability to provide a way for the hook to compare the values:

export const Price = (props) => {
  const prevPrice = usePrevious(
    price,
    (prev, current) => prev.price === current.price
  );
  ...
};
Enter fullscreen mode Exit fullscreen mode

See the codesandbox.

It might not look that useful for props, but imagine a huge object of some data from external sources there. Typically it will have some sort of id. So instead of the slow deep comparison as in the example before, you can just do this:

const prevData = usePrevious(price, (prev, current) => prev.id === current.id);
Enter fullscreen mode Exit fullscreen mode

That is all for today. Hope you found the article useful, able to use refs more confidently and use both variations of usePrevious hooks with the full understanding of the expected result ✌🏼.

...

Originally published at https://www.developerway.com. The website has more articles like this 😉

Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .