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>
);
}
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).
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("");
// ...
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)}
/>
{/* ... */}
When text is enter in the title input (triggering a re-render), the values displayed for Counter and Previous Counter become the same:
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>
);
}
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:
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>
);
}
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;
}
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>
);
}
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];
}
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>
);
}
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)