DEV Community

Cover image for Use callback refs to access individual elements in a list
Phuoc Nguyen
Phuoc Nguyen

Posted on • Edited on • Originally published at phuoc.ng

Use callback refs to access individual elements in a list

Previously, we learned about using a string to create a reference for an element via the ref attribute. However, when rendering a list of elements in React, using string refs can be difficult to access individual elements for further manipulation or interaction. Let's consider a situation where we need to render a list of items, each with a unique string ref attribute:

items.map((item) => (
    <div
        key={item.index}
        ref={`item_${item.index}`}
    >
        ...
    </div>
));
Enter fullscreen mode Exit fullscreen mode

However, accessing individual elements for further manipulation or interaction can be challenging. This is because string refs are not direct references to the underlying DOM nodes. Instead, they are just strings used to identify them. So, if we want to manipulate an element in the list, we must first find its corresponding string ref and then use that ref to locate the actual DOM node.

const itemNode = this.refs[`item__${index}`];
Enter fullscreen mode Exit fullscreen mode

Manipulating large lists or complex elements can be difficult and prone to errors. Fortunately, React callback refs can make this process much easier. With them, you can easily reference and manipulate individual elements in a list.

In this post, we'll explore the power of callback refs by building a Masonry component. Get ready to see just how useful they can be!

What is a masonry layout?

A masonry layout is a type of grid layout that arranges elements vertically, similar to how masonry stones are arranged. Unlike traditional grid layouts, each row in a masonry layout can contain a varying number of columns, with the height of each column dependent on the size of the content within it. This allows for more creative and visually appealing designs, making it an increasingly popular choice in web design.

To demonstrate a masonry layout, we will create a list of items with varying heights.

const randomInteger = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

const items = Array(20)
    .fill(0)
    .map((_, i) => ({
        index: i,
        height: 10 * randomInteger(10, 20),
    }));
Enter fullscreen mode Exit fullscreen mode

This code snippet features the randomInteger function which generates a random integer between min and max. We've used it to create 20 objects, each with an index that increases sequentially, and a random height between 100 and 200.

These properties are used to shape each item in the layout. The index property becomes the content, while the height property sets the height of the item. The height of each item is determined by the height property within its inline style attribute, which is set using the height property from its corresponding object in the items array.

Check out this example code to see how we render the list of items:

<div className="grid">
{
    items.map((item) => (
        <div
            className="grid__item"
            key={item.index}
            style={{
                height: `${item.height}px`,
            }}
        >
            {item.index + 1}
        </div>
    ))
}
</div>
Enter fullscreen mode Exit fullscreen mode

Building a masonry layout with CSS Grid

In this approach, we'll use CSS grid to create a masonry layout. As you may have noticed in the previous section's code, all items are placed inside a container with the grid CSS class.

Here's an example of what the grid CSS class looks like:

.grid {
    display: grid;
    gap: 0.5rem;
    grid-template-columns: repeat(3, 1fr);
}
Enter fullscreen mode Exit fullscreen mode

The grid CSS class creates a container that works as a grid and sets the columns' size using the grid-template-columns property. In this example, we've set the grid to have three columns of equal size with repeat(3, 1fr). The gap property sets the space between each grid item. By default, it's set to 0, but we've set it to 0.5rem in this case.

Now, let's take a look at what the grid actually looks like:

Even though the layout appears to be a grid, it has some problems. For one, every third item is placed in the same row, regardless of its size. The bigger issue is that there are empty spaces due to the varying heights of items in each row. This doesn't achieve the desired effect of minimizing blank spaces, which is a crucial feature of a masonry layout.

Tracking the size of individual elements

In order to address the issue we mentioned earlier, we will determine the height of each element and update its style accordingly.

Our goal is to create a flexible Masonry component that can arrange a list of elements in a beautiful layout. To achieve this, we have added two props to the component:

  • The gap property indicates the space between elements
  • The numColumns property indicates the number of columns

Here's an example of how the Masonry component can be used:

<Masonry gap={8} numColumns={3}>
{
    items.map((item) => (
        <div
            className="item"
            key={item.index}
            style={{
                height: `${item.height}px`,
            }}
        >
            {item.index + 1}
        </div>
    ))
}
</Masonry>
Enter fullscreen mode Exit fullscreen mode

Let's dive into how the Masonry component renders its content. Instead of rendering its children directly, we loop through each child and wrap it inside a div element. This helps us determine the height of each item and ensure that our layout looks great.

<div
    style={{
        display: 'grid',
        gridGap: `${gap}px`,
        gridTemplateColumns: `repeat(${numColumns}, 1fr)`,
    }}
>
{
    React.Children.toArray(children).map((child, index) => (
        <div key={index} ref={(ele) => trackItemSize(ele)}>
            {child}
        </div>
    ))
}
</div>;
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at this example. The root element has different styles that create a grid with a specific number of columns and a gap, based on the numColumns and gap properties. They're the same as the grid class we created earlier.

Next, we use React.Children.toArray function to loop through the children and place each one inside a div element. The wrapper has a required key property set to the index of the child element.

Now, here's the cool part: we use a callback ref function to set the ref attribute for the wrapper element. This function accepts the DOM node that represents the wrapper element.

But what does the trackItemSize function do? Don't worry about the code example below just yet. We'll dive into the details in just a moment.

const resizeObserver = new ResizeObserver(resizeCallback);

const trackItemSize = (ele) => {
    resizeObserver.observe(ele);
};
Enter fullscreen mode Exit fullscreen mode

When working with elements that have dynamic height, such as those that contain images, it's important to track their height changes in addition to calculating their initial height. The best way to do this is by using the ResizeObserver API.

To implement this, we can create a single instance of ResizeObserver and use a function called trackItemSize to track the size of each item element and update its corresponding styles. This function takes a DOM node representing the wrapper element as an argument, which is then passed to the ResizeObserver object.

By doing this, we can improve performance by having only one ResizeObserver instance for the entire component, instead of creating multiple instances for each element.

The ResizeObserver object observes changes in size for each element and calls a provided callback function when a change occurs. In this case, the callback function is defined as resizeCallback. Here is an example of what the callback function looks like:

const resizeCallback = (entries) => {
    entries.forEach((entry) => {
        const itemEle = entry.target;
        const innerEle = itemEle.firstElementChild;

        const itemHeight = innerEle.getBoundingClientRect().height;
        const gridSpan = Math.ceil((itemHeight + gap) / gap);

        innerEle.style.height = `${gridSpan * gap - gap}px`;
        itemEle.style.gridRowEnd = `span ${gridSpan}`;
    });
};
Enter fullscreen mode Exit fullscreen mode

When the callback function is called, it receives an array of entries that contain information about each observed element. For each entry, we retrieve the target item element and its first child element, which contains the actual content of the item.

Next, we calculate the height of the item by measuring its first child element using getBoundingClientRect().height. We add the gap value to this height and divide it by gap to get a grid span value. This value represents how many rows are needed to accommodate this item based on its height.

Finally, we set the updated height of the inner element by multiplying the grid span value by the gap and subtracting the gap to ensure there's no extra space between rows. We also update the number of rows this item should occupy by setting the gridRowEnd propery to span {gridSpan}.

By using a callback ref with ResizeObserver in this way, we can easily track changes in size for individual items within our Masonry component and adjust their layout accordingly.

It's important to disconnect the ResizeObserver instance when the component is unmounted. If we don't, it will continue watching elements that are no longer on the page. This can cause memory leaks and slow things down. By calling disconnect() in a cleanup function with an empty dependency array, we make sure the ResizeObserver instance is properly taken care of when the component is removed.

React.useEffect(() => {
    return () => {
        resizeObserver.disconnect();
    };
}, []);
Enter fullscreen mode Exit fullscreen mode

Check out the demo to see it in action! The layout is much better than what we achieved with pure CSS grid.


It's highly recommended that you visit the original post to play with the interactive demos.

If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

If you want more helpful content like this, feel free to follow me:

Top comments (0)