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>
));
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}`];
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),
}));
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>
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);
}
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>
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>;
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);
};
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}`;
});
};
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();
};
}, []);
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)