Table of Contents
- First of all, what is ref?
- usePrevious hook from React docs
- usePrevious hook to return the actual previous value
- usePrevious hook: deal with objects properly
There is more
Nadia Makarevich
Implementing advanced usePrevious hook with React useRef
Looking into how refs work when not attached to DOM and how to use them to implement advanced usePrevious hook to get the previous state or props value.
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}</>;};
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)} />;};
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}</>);};
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}</>);};
Now the interesting part: the same with ref.
const Counter = () => {// set ref's initial value, same as stateconst ref = useRef(0);const onClick = () => {// ref.current is where our counter value is storedref.current = ref.current + 1;};return (<><button onClick={onClick}>click to update counter</button>Counter value: {ref.curent}</>);};
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><buttononClick={() => {ref.current = ref.current + 1;}}>update ref counter</button>State counter value: {stateCounter}Ref counter value: {ref.curent}</>);};
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;};
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>);}
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>);};
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 samereturn (<><input type="text" value={name} onChange={onNameChange} /><!-- the rest is the same --></div>);}
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;};
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:
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 valuesconst 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 onlyreturn ref.current.prev;};
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;};
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 supportedconst prevProps = usePreviousPersistent(props);...};
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;};
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;};
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);...};
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);
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 âđź.