← Back toMaster of React ref

Implement a basic container query with callback refs

Written byPhuoc Nguyen
Created
12 Oct, 2023
Tags
Container queries, React callback refs, ResizeObserver
Container queries are an exciting new technique in responsive design that lets developers target styles based on the size of an element's container, rather than the size of the screen. This means we can create more flexible and adaptable layouts that adjust to their containers for a better user experience on any device.
With container queries, we have more precise control over our designs than just relying on media queries to adjust styles based on screen size. We can set rules based on the size of individual containers. For example, we can modify the number of columns in a magazine layout depending on the available space in their parent container. In smaller containers, we can decrease the number of columns, and in larger containers, we can increase the number of columns. This allows for greater flexibility and customization in our designs.
In this post, we'll learn how to implement a basic container query in React using callback refs. But first, let's explore the syntax of container queries and how they can benefit our designs. Get ready to take your responsive design to the next level!

Understanding container queries

Although the syntax for container queries is not yet standardized, there are several proposals and experimental implementations available in modern browsers. One such proposal is the `@container` rule, which allows developers to define styles based on the size of a container element.
Imagine we have a `div` element with class `magazine`. By default, it has a single column. But if it is more than 400px wide, then it will have 2 columns. If it is more than 600px wide, then it will have 3 columns. The number of columns can be set by using the CSS `column-count` property.
Here is how we can use the `@container` query:
css
.magazine {
column-count: 1;
}

@container (min-width: 400px) {
.magazine {
column-count: 2;
}
}

@container (min-width: 600px) {
.magazine {
column-count: 3;
}
}
The `@container` query works by targeting the parent container of a given element and applying styles based on its size. In the example code above, we are using the `@container` query to target the `.magazine` container and set different values for its `column-count` property depending on its width.
By using container queries instead of media queries, we can create more modular and flexible components that adapt to their containers. This allows us to create more complex layouts without relying on fixed breakpoints or complex CSS calculations.
It's important to note that this syntax is still experimental and subject to change, so it's important to check browser compatibility before using it in production code. But container queries are a promising new tool for creating more dynamic and adaptable web designs.

Building a simple container query

Let's talk about container queries in React. Unfortunately, they're not yet standardized and supported by modern browsers. But don't worry, we can still implement a simple container query in React with a bit of code.
The basic idea is to monitor the size of a container element and update its styles based on its width. We can do this using a combination of the `ResizeObserver` API and callback refs.
First, we create a `ResizeObserver` instance that listens for changes in the element's size and calls a callback function whenever it detects a change. We define a callback function called `resizeCallback` that gets called whenever there is a change in the size of the observed element.
To track the size of our container element, we define another function called `trackSize` that takes an element as its argument and adds it to the observer. We use this function as a callback ref for our container element so that it can be tracked by the observer.
tsx
const resizeObserver = new ResizeObserver(resizeCallback);

const trackSize = (ele) => {
if (ele) {
resizeObserver.observe(ele);
}
};

// Render
return (
<div ref={(ele) => trackSize(ele)}>
...
</div>
);
The `resizeCallback` function is an event listener that loops through each entry in the `entries` array using a forEach loop. For each entry, we retrieve its bounding rectangle using the `getBoundingClientRect()` method, which returns an object containing information about the element's position and size.
We then extract the width value from this object and update our component's state with this new value by calling `setWidth(rect.width)`. This causes our component to re-render with the updated width value, which we can then use to conditionally apply different styles based on its value.
tsx
const [width, setWidth] = React.useState(0);

const resizeCallback = (entries) => {
entries.forEach((entry) => {
const rect = entry.target.getBoundingClientRect();
setWidth(rect.width);
});
};
Finally, we render our container element with an inline style that sets corresponding styles based on its width. For example, if its width is less than 200px, it will have one column; if it's between 200px and 400px, it will have two columns; otherwise, it will have three columns.
tsx
<div
style={{
columnCount: width < 200 ? 1 : width < 400 ? 2 : 3,
}}
>
...
</div>

Optimizing performance

As we mentioned earlier, the `resizeCallback` function triggers the re-rendering of the entire component by updating the internal state variable. This, in turn, creates another instance of `ResizeObserver` and triggers another callback, potentially resulting in an infinite loop.
To avoid this issue, we can use the `React.useMemo()` hook to ensure that only a single instance of `ResizeObserver` is created. With this optimization, we can improve the performance of our component and avoid any potential issues with infinite loops.
tsx
const resizeObserver = React.useMemo(() => new ResizeObserver(resizeCallback), []);
To make our container query perform better, we can use the `useCallback` hook to memoize our `resizeCallback` function. This ensures that the function is only created again when its dependencies change, which never happens in this case.
By doing this, we can avoid unnecessary re-renders caused by creating a new instance of `resizeCallback` every time our component renders. Instead, we can reuse the same function instance, which improves the overall performance of our component.
Here's how to use the `useCallback` hook to memoize our `resizeCallback` function:
tsx
const resizeCallback = React.useCallback((entries) => {
...
}, []);
When we pass an empty array as a second argument to `useCallback`, we're telling React that this function doesn't rely on any other data in our component. So, React only creates it once and doesn't waste resources recreating it unnecessarily.
Finally, to prevent memory leaks and performance issues, it's crucial to disconnect the `ResizeObserver` instance when the component unmounts. Neglecting to do this will cause the observer to continue tracking size changes for elements that no longer exist in the DOM, leading to unexpected behavior and even crashes.
To disconnect the observer when our component unmounts, we can use the `useEffect` hook with an empty dependency array. This ensures that we tidy up any resources related to the observer before it's removed from the DOM.
tsx
React.useEffect(() => {
return () => {
resizeObserver.disconnect();
};
}, []);

Demo

Now let's take a look at the final result of the steps we've been following. To see how the number of columns adjusts, try dragging the element on the right side. As you move it left or right, the size of the container will change, which will update the layout accordingly. Give it a try!
Callback refs allow us to create more flexible and adaptable layouts in our React applications. This means that our components can respond to changes in their container elements, instead of relying solely on media queries.
By using this technique, we can create components that adapt their layout based on their container's size. This allows us to create more flexible and responsive designs without relying on fixed breakpoints or complex calculations. While this approach isn't yet widely supported by browsers, it provides a useful workaround until native container queries become available.
If you found this post 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!

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 🥷.
Ask me questions

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