The Current Model
At a macro level, designs are typically optimized for page size. For the most part, this makes complete sense, as you’ll rarely want to serve the same page full of content to a smartphone as you would serve to a standard desktop user, and you would rarely want to serve a pared-down page to a desktop user like you might to a smartphone user.
There are some drawbacks to designing this way, from the implementation side of things. One major issue is that, depending on the complexity of the layout, the prioritized content, etc, you may need to bloat your page with additional content, which may or may not be shown, as it may be shown or hidden based on media query breakpoints. Another issue is that any individual page components that may need to be differentiated based on its own available screen real estate has no way to do so. Since media queries are based on aspects of the overall page, they’re unreliable for use at the component level.
A glaring example of this is in the use of data grid-style components. You know the ones. Some monstrosity of a table, no matter how well-styled, possibly with sortable columns, checkboxes to highlight rows, all sorts of fancy interactions, and a ton of data. There’s no good way to display something like that in a mobile form factor, and that’s ok sometimes. Sometimes we can get away with rendering a couple of different variations of these, and use media queries to show and hide them as appropriate, as mentioned up above.
But what happens when some developer wants to take your big, bloated data grid and stick it into a container that was never meant to hold something that size? What if they want to take a tabular list of online users and stick it into a 300-pixel left column? Any number of things: it could blow out that column’s width and shift every other element in the page; it could trigger a horizontal scrollbar within the column itself, forcing the user to scroll through this narrow container’s contents in order to reveal the full grid of data; it might even trigger a horizontal scrollbar on the data grid itself, revealing 1-2 columns of data at a time while the user tries to make sense of it.
Ideally, when rendering a data grid on different device types, we would render data in both a depth and style appropriate for the device at hand. On a desktop browser, we should just be able to throw as many columns and rows on the screen as we want (within reason). On a tablet, we can probably get away with most of the same thing, but we may want to prioritize the data we’re displaying, so that instead of 10-12 columns worth of items for each record, we maybe pare it down to 5-6 columns of the more important items for each record. On a smartphone, we might take it a step further, not only shrinking back to 3-4 of the most important items per record, but maybe even removing the “grid” aspect altogether, and making a vertically-tiled list of cards that render those data points in a nicely-formatted way for reading on a phone screen.
Wouldn’t it be nice if we could author a single component for use within an application, and have it lay itself out in the most appropriate fashion, no matter what type of device it was on, and no matter where in the page it lived? Ideally, if we took our previous example of dropping a grid full of user data into a 300-pixel left column on the page, it could see that it only has 300 pixels to render into, and automatically use its “mobile” layout, rendering a set of vertical tiles of only the most important data. If that component later cut-and-pasted from the left column into the main body of the page, it may render itself in tablet or desktop modes, depending on how much room it had to spread itself into.
Smart UI components should be able to both determine the size of the screen real estate they’ve personally been given to fill, and render themselves appropriately, based on that container size. Using a library like React means that we can wrap that “smart” aspect up under a single root component, which becomes easily portable throughout the applicable, and a perfect metaphor for the semantics of web design, wherein you’re simply dropping in a
UserTable or a
SelectableList or some other kind of idiomatic component, without having to think about where it may render on the page, or on what form factor or device it will be viewed by your end users.
One major win for this style of development is that you don’t need to even try to stick to a singular HTML implementation that can be tweaked with CSS properties based on media queries, nor do you need to bloat your page with multiple variations of these components, showing or hiding them with media queries. The component itself can simply choose an appropriate layout for itself, and render only that one. In addition, by giving up the idea of media queries, we don’t run the risk of other containers on the page screwing up our layout.
Imagine having a side pane hidden, with a hamburger icon to display it, but when it transitions into view, rather than overlapping your content, it squeezes in alongside it, adjusting the shape of your other page elements. With traditional media queries, nothing would be changed at all, and you may potentially squeeze your other components to illegibility. By using container sizes to control the rendering of your components, this is absolutely accounted for, and a new layout may be selected if its container is squeezed past a certain point.
Your smart components may own as much or as little of their size delineations as you allow. They may completely internalize those decisions and strictly maintain their own layout governance, allow an external consumer to specify different components to be used at different container sizes, or provide a mixed mode that allows for sane defaults to be applied, while also allowing consumers to override those defaults with props.
If we were to walk through what a setup like this might entail, we’ll need to first define some foundational aspects from which we can later compose these smart components. First and foremost, we’ll need some way to measure our current container, and inform our smart components of exactly how much space they have to use on the page. One method for that, the one we’ll use here, is the ResizeObserver API. Another method, which has been requested for years and looks to finally be coming to fruition, is container queries, which would allow us to specify some of these rendering aspects right in our CSS, in a similar syntax to media queries. While this can solve for certain simple use cases, like tweaking CSS properties to change order and layout styles based on container sizes, it will still suffer from the same need to bloat your page with additional variations of unused markup in more complex scenarios.
ResizeObserver component in React is a snap. It doesn’t need to render anything itself, it just needs to register a callback to be invoked when its container is resized, and then render any child components specified by its consumer, passing that height and width information down to them. For situations like this, I prefer to use a render callback via the
children prop, and simply inject the data points as inputs to the callback. Take a look at the src/components/resize-observer.js file in the demo below to see just how we do this, then check out the src/components/resizer.js file for an example of how we might consume it to make a
Resizer component that simply reports its dimensions.
This component simply subscribes a
ResizeObserver, creates a ref to attach to that container, then passes the height/width of the container, along with the ref, to a child component via a render callback function. Using this, we can now create a
ContainerQuery component that does nothing but store measurement references to be used for determining when to render its children. We’ll use a render callback function again, so that no child component instances are created and discarded unnecessarily. They won’t be constructed until a match is determined, and their
children function is invoked.
With these two items, we can now easily create a
SmartComponent to determine when we’re matching a particular form factor, by attaching the received ref to our own component’s wrapper element. We can then iterate over our child ContainerQuery components and find the matching child for our current container size.
Take a look at the src/components/container-query.js and src/components/smart-component.js files to see how we build on the previous layers of code for our new support components, then check out src/components/smart-resizer.js for an example of how to combine it all.
SmartComponent type uses a
ResizeObserver component itself, and within its render callback, attaches the passed ref to a div it renders as the component’s internal container. It then iterates over all of its children, and finds the first match that passes all of its size checks, to make sure the matched child is within any defined height/width constraints. If no matching children are found, it simply renders
null. Now, we can put all of these pieces together. This final demo creates a
SmartComponent version of a
UserDataGrid, in mobile-, tablet-, and desktop-style form factors.
Feel free to look at each of those in the src/components/user-data-grid folder. The tablet version is very similar to the desktop version, but pares down the number of displayed columns so that we’re focusing on more important data with our minimized display space. The mobile version scraps the notion of a display grid entirely, but still manages to fit all of the relevant data into a more appropriate interface, hiding address/email/phone information behind some icons, and better organizing it for viewing on a mobile device.
The provided page layout splits the screen into 3 sections: an upper full-width half of the screen, and a lower two-column layout, with a 300-pixel left column, and the remainder of screen space in the right column. The same component is dropped into all 3 of those containers, and in all of those containers, it renders appropriately based on the space it has. The parent
SmartComponent holds the state for all 3 variants, so any selections of records are maintained as these variants are swapped in and out based on the overall container sizes as you resize the window.
Once the individual variants have been written, composing them into a smart version really isn’t difficult at all. We now have a singular component implementation of a
UserDataGrid that can be dropped anywhere in the application, and when it initializes, the
SmartComponent aspect will filter the children to find the best match for the container it’s been placed in, and return the appropriate component, proxying the props straight through to it. Any interaction that causes its container to change dimensions will also cause it to reevaluate which variant of the component should be rendered.
One thing to reiterate about this method of implementation is that moving between breakpoints is going to cause the unmount of a child component, and remount of another child component. Because of this, any state, reducers, memoized values, etc. should be hoisted to the level of the root
SmartComponent, and passed down to all children as props. Otherwise, any stateful value(s) will be lost as one component unmounts and another mounts.
Additionally, to avoid bloating the size of your code bundle that’s being shipped to the client, you may consider lazy-loading the individual variants of your components, so that their code modules are only ever requested when the UI determines that they’re needed. In most cases, a user’s UI form factor is going to stay fairly static throughout the lifetime of their session, so the 90% use case would most likely only ever need a single variant loaded, while allowing for the others to be loaded on demand.
Componentization of web-based applications has made it easy to rapidly prototype things that were previously complicated piles of code. By thinking of components as isolated modules of UI rendering and functionality, we can very quickly create complex interfaces. We should now also start considering that individual components themselves may become sufficiently detailed and complex enough that they might merit a little more upfront planning to create optimized variants based on their own allocated space on the screen. At the same time, we don’t want to make it cumbersome to the development process to have to consider which variants should be placed where at design time. An approach like this allows that complexity to be hidden away, and to present a single interface that’s intelligent enough to determine its own optimal rendering at runtime.