Use callback refs to access individual elements in a list
Written byPhuoc Nguyen
Created
11 Oct, 2023
Tags
Masonry layout, React callback refs, ResizeObserver API
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:tsx
items.map((item) => (
<div
key={item.index}
ref={`item_${item.index}`}
>
...
</div>
));
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.
tsx
const itemNode = this.refs[`item__${index}`];
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.
js
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),
}));
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:
tsx
<div className="grid">
{
items.map((item) => (
<div
className="grid__item"
key={item.index}
style={{
height: `${item.height}px`,
}}
>
{item.index + 1}
</div>
))
}
</div>
#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:css
.grid {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(3, 1fr);
}
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:
tsx
<Masonry gap={8} numColumns={3}>
{
items.map((item) => (
<div
className="item"
key={item.index}
style={{
height: `${item.height}px`,
}}
>
{item.index + 1}
</div>
))
}
</Masonry>
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.tsx
<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>;
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.tsx
const resizeObserver = new ResizeObserver(resizeCallback);
const trackItemSize = (ele) => {
resizeObserver.observe(ele);
};
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:tsx
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}`;
});
};
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.tsx
React.useEffect(() => {
return () => {
resizeObserver.disconnect();
};
}, []);
Check out the demo to see it in action! The layout is much better than what we achieved with pure CSS grid.
Questions? 🙋
Do you have any questions about front-end development? If so, feel free to create a new issue on GitHub using the button below. I'm happy to help with any topic you'd like to learn more about, even beyond what's covered in this post.
While I have a long list of upcoming topics, I'm always eager to prioritize your questions and ideas for future content. Let's learn and grow together! Sharing knowledge is the best way to elevate ourselves 🥷.
Recent posts ⚡
Newsletter 🔔
If you're into front-end technologies and you want to see more of the content I'm creating, then you might want to consider subscribing to my newsletter.
By subscribing, you'll be the first to know about new articles, products, and exclusive promotions.
Don't worry, I won't spam you. And if you ever change your mind, you can unsubscribe at any time.
Phước Nguyễn