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.