DEV Community

Cover image for Implement a basic container query with callback refs
Phuoc Nguyen
Phuoc Nguyen

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

Implement a basic container query with callback refs

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:

.magazine {
    column-count: 1;
}

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

@container (min-width: 600px) {
    .magazine {
        column-count: 3;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

const resizeObserver = new ResizeObserver(resizeCallback);

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

// Render
return (
    <div ref={(ele) => trackSize(ele)}>
        ...
    </div>
);
Enter fullscreen mode Exit fullscreen mode

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.

const [width, setWidth] = React.useState(0);

const resizeCallback = (entries) => {
    entries.forEach((entry) => {
        const rect = entry.target.getBoundingClientRect();
        setWidth(rect.width);
    });
};
Enter fullscreen mode Exit fullscreen mode

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.

<div
    style={{
        columnCount: width < 200 ? 1 : width < 400 ? 2 : 3,
    }}
>
    ...
</div>
Enter fullscreen mode Exit fullscreen mode

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.

const resizeObserver = React.useMemo(() => new ResizeObserver(resizeCallback), []);
Enter fullscreen mode Exit fullscreen mode

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:

const resizeCallback = React.useCallback((entries) => {
    ...
}, []);
Enter fullscreen mode Exit fullscreen mode

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.

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

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.


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)