DEV Community

Aneesa Saleh
Aneesa Saleh

Posted on

How to Track Previous State in React

When developing React applications, we may need to keep track of both the current and previous state. This article discusses various techniques for achieving this, and how to encapsulate the logic into reusable custom hooks.

To demonstrate the examples, we'll use a basic application that displays a counter, its previous value and a button to increment it:

Let's explore how to build this component.

Approach 1: Use Refs and Effects

A common approach is to use a ref to store the previous state:

function App() {
  const [counter, setCounter] = useState(0);
  const ref = useRef();

  useEffect(() => {
    ref.current = counter;
  }, [counter]);

  return (
    <div>
      <div>Counter: {counter}</div>
      <div>Previous Counter: {ref.current}</div>
      <button onClick={() => setCounter(counter + 1)}>
        Increment counter
       </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

An effect monitors counter and updates ref.current when it changes. Changing a ref's value won't trigger a re-render, so the element displaying ref.current shows the previous value of counter until the next render (i.e. when counter changes).

Tracking counter's previous state using a ref

This approach would work fine when the only state variable in our component is counter, but once there are multiple state variables (as most components have), a re-render that isn't triggered by counter would synchronise the elements displaying counter and ref.current, displaying the same value for both.

To see this in action, let's add a new state variable, title:

// ...
const [counter, setCounter] = useState(0);
/* 👇 add title here */
const [title, setTitle] = useState("");
// ...
Enter fullscreen mode Exit fullscreen mode

Add an input element to display and update title:

{/* ... */}
<div className="App">
{/* 👇 add the input here */}
  <input
    value={title}
    onChange={(e) => setTitle(e.target.value)}
  />
{/* ... */}
Enter fullscreen mode Exit fullscreen mode

When text is enter in the title input (triggering a re-render), the values displayed for Counter and Previous Counter become the same:

Counter and Previous Counter are the same when title is updated

In general, you want to avoid using refs for values that will be displayed, as stated in the React docs:

Changing a ref does not trigger a re-render, so refs are not appropriate for storing information you want to display on the screen. Use state for that instead.

Approach 2: Use the state setter function

To fix the state-ref synchronisation bug, another approach is to use a new state variable previousCounter to keep track of counter's previous value:

function App() {
  const [counter, setCounter] = useState(0);
  /* 👇 add previousCounter here */
  const [previousCounter, setPreviousCounter] = useState(null);
  const [title, setTitle] = useState("");

  /* 👇 add an event handler for the increment button */
  const handleIncrementButtonClick = () => {
    setCounter((counter) => {
      setPreviousCounter(counter);
      return counter + 1;
    });
  };

  return (
    <div className="App">
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <div>Counter: {counter}</div>
      <div>Previous Counter: {previousCounter}</div>
      {/* 👇 update the button's onclick handler */}
      <button onClick={handleIncrementButtonClick}>Increment counter</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The value of previousCounter is updated when setting counter in the click event handler for the increment button. Now Counter and Previous Counter remain synchronised even when the title is updated:

Counter and Previous Counter remain synchronised even when the title is updated.

With this approach, we've eliminated the need for using both refs and effects. Effects are considered an escape hatch, so replacing them with state wherever possible makes our code more stable. Now we'll see how to extract this logic into a custom hook.

Approach 3: Use two state variables to track current and previous values

In this approach (adapted from the usehooks package), we'll define a state variable currentCounter to track counter's value:

function App() {
  const [counter, setCounter] = useState(0);
  /* 👇 add currentCounter here */
  const [currentCounter, setCurrentCounter] = useState(counter);
  const [previousCounter, setPreviousCounter] = useState(null);
  const [title, setTitle] = useState("");

  /* 👇 conditionally update previousCounter and currentCounter  */
  if (counter !== currentCounter) {
    setPreviousCounter(currentCounter);
    setCurrentCounter(counter);
  }

  /* 👇 update event handler */
  const handleIncrementButtonClick = () => {
    setCounter(counter + 1);
  };

  return (
    <div className="App">
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <div>Counter: {counter}</div>
      <div>Previous Counter: {previousCounter}</div>
      <button onClick={handleIncrementButtonClick}>Increment counter</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The initial value of currentCounter is set to match counter. On subsequent re-renders, when currentCounter and counter don't match, that means counter has been updated and currentCounter now holds its previous value. We set previousCounter to currentCounter's value, and update currentCounter to match counter. Now the handleIncrementButtonClick event handler only needs to increment counter.

Both state and props can be tracked using this method. The logic easily be extracted into a custom hook:

function usePrevious(value) {
  const [current, setCurrent] = useState(value);
  const [previous, setPrevious] = useState(null);

  if (value !== current) {
    setPrevious(current);
    setCurrent(value);
  }

  return previous;
}
Enter fullscreen mode Exit fullscreen mode

To use this hook, we pass it the state or prop we want to track:

function App() {
  const [counter, setCounter] = useState(0);
  /* 👇 this is all we need now */
  const previousCounter = usePrevious(counter);
  const [title, setTitle] = useState("");

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

  return (
    <div className="App">
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <div>Counter: {counter}</div>
      <div>Previous Counter: {previousCounter}</div>
      <button onClick={handleIncrementButtonClick}>Increment counter</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Approach 4: Use a custom hook to set both current and previous values

For tracking a value within a component's state (this won't work for props), we can use a custom hook that returns a state variable, its setter and another state variable to track its previous state:

function usePreviousStateTracking (initialValue) {
  const [current, setCurrent] = useState(initialValue);
  const [previous, setPrevious] = useState(null);

  function setPreviousAndCurrent(nextValue) {
    setPrevious(current)
    setCurrent(nextValue)
  }

  return [current, setPreviousAndCurrent, previous];
}
Enter fullscreen mode Exit fullscreen mode

The hook can be used like this:

export default function App() {
  /* 👇 this is all we need now */
  const [counter, setCounter, previousCounter] = usePreviousStateTracking(0);
  const [title, setTitle] = useState("");

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

  return (
    <div className="App">
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <div>Counter: {counter}</div>
      <div>Previous Counter: {previousCounter}</div>
      <button onClick={handleIncrementButtonClick}>Increment counter</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In some cases, you might want to use this approach to skip the extra re-render from approaches 2 & 3 (caused by setting the previous state value separately from the current one). It's worth noting that when an additional re-render is causing notable performance issues, it may indicate that other optimisations need to be made in your component.

Conclusion

In this article, we explored various approaches to track previous state in React components. We started by solving the problem using refs and effects, then discussed different approaches that use only state. Finally, we used custom hooks to encapsulate the logic into reusable functions.

If you're interested in seeing how to track multiple versions of a state variable, be sure to leave a comment below.

Top comments (0)