ref is one of the most mysterious parts 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 đ
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}</>;
};
jsx
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)} />;
};
jsx
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}
</>
);
};
jsx
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}
</>
);
};
jsx
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}
</>
);
};
jsx
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}
</>
);
};
jsx
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;
};
jsx
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>
);
}
jsx
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>
);
};
jsx
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>
);
}
jsx
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.
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;
};
jsx
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.
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;
};
jsx
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!
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;
};
jsx
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);
...
};
jsx
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;
};
jsx
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;
};
jsx
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
);
...
};
jsx
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);
jsx
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 âđź.