Window pains: using element queries in your React app

Update: A previous updated mentioned using @react-hooks/size, but my testing indicates it is unreliable. Sometimes it doesn’t return an initial size, sometimes it doesn’t update on size changes.

Instead, I’ve rebuilt useElementWidth as useElementSize, which now includes height alongside width. A future update will include performance improvements, but if you’re only using it on a handful of elements (e.g., observing a list container’s width, not that of every list item) in a view, it’s perfectly fine as-is.

Old Update: Since publishing, I’ve discovered useSize, which should absolutely be used in place of the useElementWidth hook mentioned in this article. The concepts are the same, but useSize is more performant.

Everyone who works on web interfaces knows about media queries. It’s the core CSS feature that enables “responsive” web design. A media query allows us to style elements differently in one window (“viewport”) than in another.

.el {
  width: 100%;
}

@media (min-width: 480px) {
    .el {
        width: 450px;
    }
}

For many websites, this all you’ll ever need to make your UI adapt to every possible viewport.

The problem

Modern web (React) applications are a bit more complicated. Component elements frequently need to adapt to the changing size of more than just the browser window.

Note: for this article we’re focusing on width. The same concepts can be applied to height, but most app designs tend toward elements like left and right sidebars that would affect the width of interior elements.

Does this look familiar?

const Element = () => <div className={styles.element} />
.element {
    font-size: 16px;
}

:global(.right-sidebar-open) .element {
    font-size: 14px;
}

:global(.left-sidebar-open.right-sidebar-open) .element {
    font-size: 12px;
}

What about this one?

const Element = () => {
    const width = window.innerWidth // plus other stuff to react to window size changes
    const { 
        rightSidebarOpen, 
        leftSidebarOpen,
    } = useContext(context)

    let colClass = 'col-5'
    if (width < 480 && !rightSidebarOpen && !leftSidebarOpen) {
        colClass = 'col-1'
    } else if ((width < 780 && !rightSidebarOpen && !leftSidebarOpen) || (width < 480 && (rightSidebarOpen || leftSidebarOpen))) {
        colClass = 'col-2'
    } else if ((width < 1024 && !rightSidebarOpen && !leftSidebarOpen) || (width < 780 && (rightSidebarOpen || leftSidebarOpen))) {
        colClass = 'col-3'
    } else if ((width < 2048 && !rightSidebarOpen && !leftSidebarOpen) || (width < 1024 && (rightSidebarOpen || leftSidebarOpen)) {
        colClass = 'col-4'
    }

    return (
        <div className={`${styles.element} ${colClass}`} />
    )
}

To be honest, I have no idea how the logic in that example ended up. It’s far too confusing to be scalable.

The most common scenario I’ve seen is to rely on some global percentage-based columns and give no consideration whatsoever to element sizing.

const Input = () => <input className='col-1' />

When a sidebar or some other element squishes this element’s parents enough, the interior text of the input becomes chopped off.

The solution

What if each of your components was aware of its current container size, with no need to tap into global classes or contexts?

Unfortunately, CSS element queries don’t have much traction. And even if they did, sometimes your JavaScript logic also needs to know about element sizing.

There have been some interesting solutions proposed over the years, some that involve hacks around scroll events. A long time ago I even developed a solution for jQuery that relied on appending iframes to elements and listening to their resize events (still in use here).

But the web gods have given us a better solution: ResizeObserver.

const resizeObserver = new ResizeObserver(entries => {
    entries.forEach(entry => {
        // width
        console.log(entry.contentBoxSize[0].inlineSize)
    })
})

resizeObserver.observe(document.querySelector('.element'))

Oh dang. We can now listen to the changing size of any element and grab its width!

We could even centralize this logic into a hook to make it easy for any of our small components to tap into.

const Element = () => {
    const ref = useRef()
    const width = useElementWidth(ref)

    const classNames = width > 300 ?  styles.wide : ''

    return (
        <div ref={ref} className={`${styles.el} ${classNames}`} />
    )
}
.el {
    width: 100%;
    display: flex;
    flex-direction: column;
}

.wide {
    flex-direction: row;
}

Now our most basic components don’t need to know anything about the window, which sidebars are open, or which features are enabled. They just need to know what size they’re currently being allotted so they can make the simple layout changes they need to make.

We could expand this even more, maybe create a reusable Container that lets any child component know which version of its layout to use.

const Component = ({ children, breakpoint }) => {
    const ref = useRef()
    const width = useElementWidth(ref)
    const size = width < breakpoint ? 'narrow' : 'wide'

    return (
        <div ref={ref}>
            {React.Children.map(children, child => {
                if (!child) return child
                // pass `size` to children that are React components
                if (typeof child.type === 'function') {
                    return React.cloneElement(child, { size })
                }

                return child
            })}
        </div>
    )
}
const Element = ({ size }) => (
    <div className={styles[size]} />
)

// decide what parent width breakpoint makes sense for this component
const ElementWithContainer = () => {
    <Container breakpoint={300}>
        <Element />
    </Container>
}

The exact implementation depends on your app’s needs. Obviously, if you’ll have components that need more than two layouts, you’ll want to expand this. Maybe instead of a single breakpoint, you pass a map of breakpoints with their desired string designator.

The ability for layout changes to take place at the point they’re actually needed is a leap forward in building smart responsive layouts. Concerns stay separated and scalable, and CSS remains scoped to the smallest pieces possible.

It’s not all roses. The ResizeObserverAPI differs between browsers. And what about those constant and pesky Safari errors?

The good news is I’ve released the hook useElementWidth as an npm package that handles these scenarios for you. Pass it a ref and build better.

I’ll leave you with some examples. These are using the useElementWidth hook with a Group container that allows them to adjust their layout based on the container’s size. As the parent container changes width—regardless of whether the window changes—its contents adjust smoothly.

Tell me I’m wrong.

Illustration by Gwyneth Fitzsimmons.