Why use refs in React
We recently moved from a Vanilla JS stack to Next.js. While the same basic front-end principle stays the same, the way to approach things could change. A good example of this is the document.querySelector
.
Sometimes you need to get some information from a DOM element. For instance, you would like to know the height of an element for some calculations. Within a Vanilla JS stack you would simply use the querySelector
or querySelectorAll
function. Within React the approach is a bit different. But why?
Refs
Introducing the ref
. A ref
is an object that holds a mutable value in its current
property. You can place all sorts of values here, but in this post we will focus on DOM elements.
You can use the ref
object to get a reference to a DOM element. This example uses the useRef
hook for this.
const ref = useRef(null);
return <div ref={ref}>An item</div>;
It’s now possible to access the properties of the DOM element by using the current
property.
export function Component() {
const ref = useRef(null);
useEffect(() => {
// You now have access to the height, width etc.
const { height, width } = ref.current.getBoundingClientRect();
// Or any other property that you expect to find here (same as with a querySelector)
const parent = ref.current.parentNode;
}, []);
return <div ref={ref}>An item</div>;
}
Multiple DOM elements
The above example needs access to a single DOM element. Often, you will need acces to more than one element. A list comes to mind.
<ul>
<li>List item 1</li>
<li>List item 2</li>
<li>List item 3</li>
<li>List item 4</li>
<li>List item 5</li>
</ul>
If you need to know the height of each single item in the list to, for example, provide a color to each list item that’s bigger than 20% of the list height. You can’t simply declare a ref for each item in this list because most lists aren’t based on a fixed number of items. And even when the number of list items is fixed, it won’t look very clean.
Within a Vanilla JS environment, the querySelectorAll
method would be the solution.
useEffect(() => {
const items = document.querySelectorAll("li");
items.map((item) => {
// Get the height of each element.
const { height } = item.getBoundingClientRect();
});
}, []);
This code works, but not always.
Initially, this code will work. You can calculate the height of each item in the list. But when this list updates, the references to this list won’t update [along with it]. Let’s imagine that the list will grow with another 4 items, the amount of items
derived from the querySelectorAll
will stay at 5. Not 9 like we’d hoped.
This is all due to one of the biggest powers of a framework like React, it’s Reactivity.
Losing the Ref
React will update the DOM when a state update occurs. Let’s stay with the same example as above. When the state that holds the list items will increase from 5 to 9, the UI will reflect this. That’s one of the biggest benefits of a framework like this.
Because we don’t listen to any changes to the DOM, we cannot tell the querySelectorAll
that there are items added to the DOM. You could actually do listen to changes to the HTML, but then you would step out of the framework and that will bite you in the end. Believe me, I’ve tried!
The React approach
How should you solve this within React? Again, introducing the ref
here! Let’s take a look at a possible implementation that uses 2 components to create a ref for each List item.
// List component
export function List(items) {
const listRef = useRef(null);
const [listHeight, setListHeight] = useState(0);
const [items, setItems] = useState([
{ id: 1, title: "1" },
{ id: 2, title: "2" },
{ id: 3, title: "3" },
{ id: 4, title: "4" },
{ id: 5, title: "5" },
]);
useEffect(() => {
// Bail out if listRef.current doesn't exist.
if (!listRef.current) return;
// Get the height of the list and set it to state.
const { height } = listRef.getBoundingClientRect();
setListHeight(height);
// FYI you should also do this on resize, but let's leave that for now.
}, []);
return (
<ol ref={listRef}>
{items.map((item) => (
<li key={item.id}>
<ListItem item={item} listHeight={listHeight} />
</li>
))}
</ol>
);
}
// The component
export function ListItem({ id, title, listHeight }) {
const ref = useRef(null);
const [isMoreThanTwentyPercent, setIsMoreThanTwentyPercent] =
useState(false);
useEffect(() => {
// Bail out if ref.current doesn't exist.
if (!ref.current) return;
// Get the height of the item and set it to state.
const { height } = ref.getBoundingClientRect();
setItemHeight(height);
// FYI you should also do this on resize, but let's leave that for now.
}, []);
useEffect(() => {
const twentyPercentOfListHeight = (listHeight / 100) * 20;
if (itemHeight > twentyPercentOfListHeight) {
// Do something like make the element pink.
setIsMoreThanTwentyPercent(true);
}
}, [itemHeight, listHeight]);
return (
<div ref={ref} data-is-too-big={isMoreThanTwentyPercent}>
{title}
</div>
);
}
By using a component for each item in the list, we can easily make a ref for each item in the list. Regardless of the amount of items with it or the number of times that it updates.
Conclusion
You can use the querySelector
and querySelectorAll
within React. The downside it that you can lose the reference to the DOM element due to Reacts Reactivity. The solution is to use the useRef
hook provided by react to store a reference to the DOM element. This ref will keep the reference to the DOM element, even when it changes (mounts, unmounts, updates) due to Reacts Reactivity.