Designing Responsive React Components for 2023

Designing responsive web sites is a tale as old as CSS media queries, but with the proliferation of JS component libraries, like React, there is a trap that is starting to become a thorn in the side of rising popularity of server-side rendered (SSR) React.

The trap is that conditionally rendering above the fold components based on values from runtime-derived media queries, results in a compromised experience. With a server rendered page, the user will either receive HTML for an incorrect device, or be flashed with a blank page or similar loading state while the site finishes booting.

The fix for this problem involves creating components with responsive props; props that describe how the component’s variant changes at various breakpoints. Not using SSR? Doesn’t matter. I think one should start creating environment agnostic components, as I don’t see too many limitations with this approach.

I’m far from a trailblazer on this topic of designing responsive components. Here’s an article about Server-Rendering Responsively from four years ago.

I think the JS community is starting to come around to this idea, surprisingly slowly. The literature seems sparse. I only found the aforementioned article through a github issue. When I do a web search for how to design responsive React components, the first hit is the article “4 Patterns for Responsive Props in React”. In the article, SSR is an afterthought and gives pretty poor advice:

There is no screen on the server so no breakpoints will be active. The best way to handle this situation is to choose a breakpoint that is active by default

No, don’t do that. Don’t show your mobile users a desktop view or your desktop users a mobile view. Don’t create a suite of components that are at risk of a rewrite if your company wants to adopt SSR.

If you were to ask a developer to create a button that was small on mobile devices but larger on desktop, they’d write something like:

// import { Button } from 'antd';
import Button from '@mui/material/Button';

function MyComponent() {
  const isMd = useBreakpoint("md");
  return (
    <Button size={isMd ? "medium" : "small"}>
      Hello
    </Button>
  );
}

But this is what we want to avoid! We should be avoiding conditional rendering based on device size.

I don’t think this is the fault of component frameworks like MUI and Ant for exposing an API like this, as they need to balance flexibility with ergonomics. If they weren’t so easy to get started with, maybe they wouldn’t have gotten so popular.

There are several routes that I believe are better for responsive components.

Render all variants

When you need conditional logic to toggle two disparate components, consider rendering both and hiding the undesired variants with CSS media queries. I’ll be using Tailwind for demonstration purposes.

A great example of this is when I needed to show a carousel of images on mobile, while the desktop view needed a more mouse friendly experience:

function ImageGallery() {
  return (
    <>
      <div className="hidden lg:block">
        <DesktopImageGallery />
      </div>
      <div className="lg:hidden">
        <MobileImageGallery />
      </div>
    </>
  );
};

We’re giving the browser everything. All the context needed to decide which gallery should be displayed. The DOM won’t be drastically changing as the screen resizes. As a side note: I prefer creating a wrapper div around the components and applying the responsive classes there so that the children don’t need to be cracked open to accept arbitrary class names if that can be avoided.

The downside here is that as Dan Abramov warns, since our implementation renders all variants, all encapsulated side effects are also executed. If this is bad news for you, I’d recommend making the side effect contingent on device info, or consider if the side effect can be hoisted up and removed from the children. A small price to pay in my opinion.

One side effect could be client side data fetching. These should be disabled, unless a JS media query matches. See how Tanstack Query exposes an API for this. While duplicating media queries in CSS and JS is unsavory, I like the mental model that CSS media queries control styles and layout, while JS media queries are for JS behavior like application state. Plus, the more colocated these two types of media queries are, the less of an issue it is.

A more subtle side effect could be an <img> loading. A hidden image is still allowed to load, potentially wasting time and resources. W3 has a page just for testing this. On Chrome and Firefox 111, the image is loaded behind the scenes. Maybe this is desirable, maybe it’s not. If it is not desirable, and the images are below the fold, consider the loading="lazy" attribute as it is decently well supported. Otherwise, if the two components use a different set of images that are important, introduce responsive images so the images are loaded only at the desired sizes.

We’re starting out with a lot of caveats, but the subsequent solutions don’t suffer from the same issues as they are less generic.

Responsive prop variant

Often enough, conditional rendering flips between two values. Say we have a list that could either be horizontal, vertical, or start out as vertical and transition to horizontal at larger screen size:

function MyComponent() {
  const isMd = useBreakpoint("md");
  return (
    <List layout={isMd ? "horizontal" : "vertical"}/>
  );
}

A solution is to add a 3rd variant to layout, one that conveys responsiveness.

function MyComponent() {
  return <List layout="responsive">;
} 

Which seems easy enough to support within the component

type Layout = "horizontal" | "vertical" | "responsive";
function layoutClasses(layout: Layout) {
  switch (layout) {
    case "horizontal": return "flex-row";
    case "vertical": return "flex-col";
    case "responsive": return "flex-col md:flex-row";
  }
}

Libraries that help manage component variants, like Class Variance Authority recommend this approach for achieving responsive variants.

But what if there is more than one responsive variant? What if there are so many that enumerating them is prohibitive?

Caller responsive styles

Back to buttons. An application may have many types of responsive buttons. How buttons respond to screen size varies on use, whether it is a call to action, contained within a modal, navigation, or a form.

If designers want slightly different sizings for these button use cases, it’s up to us to devise a solution that makes this possible without headache.

Taking some inspiration from styled-system’s responsive styles, we’ll push the definition onto the caller, so they describe how a component should behave at given breakpoints.

Before the definition of the component, let’s see usage:

function MyComponent() {
  // A button that is small initially but grows larger
  return (
    <MyButton sizes={["btn-sm", "md:btn-lg"]}>1</Button>
  )
}

And now the definition:

type Breakpoints = "sm" | "md" | "lg" | "xl";
type ButtonSize = "sm" | "lg";
type ButtonClassName = `btn-${ButtonSize}`;
type ButtonSizeProp = [
    `${ButtonClassName}`,
    ...`${Breakpoints}:${ButtonClassName}`[]
];

type ButtonProps = React.PropsWithChildren<{
  sizes: ButtonSizeProp
}>;

export function MyButton({ sizes, children }: ButtonProps) {
  const sizeClassName = sizes.join(" ");
  return <button className={sizeClassName}>{children}</button>
}

Whoa, what’s going on?

Most of the code is related to creating a type that ensures callers give a valid size class for the component, where the first class in the array must not have a screen size prefix, and subsequent classes must have one. Class order doesn’t matter. Typescript is powerful enough that it will validate every possible combination.

In exchange, the caller provides complete class names as Tailwind does not handle dynamic class names, so we need to declare these classes upfront.

@layer components {
  .btn-sm {
    @apply p-4 text-sm /* ... */;
  }
}

By forcing the caller to provide complete class names, tailwind will only emit styles that are actually used. No caller using sm:btn-lg? No problem, it won’t be emitted. This should help manage potentially large combinations of classes resulting from variants mixed with breakpoints.

I acknowledge that @apply is contentious. Tailwind’s creator has spoken out against it, and the docs caution its usage. But a primitive like a button is so foundational that I think an exception should be made. Typography potentially being another exception.

If it is annoying for component consumers to repeat the same responsive definition, one can always create aliases, in the same vein as a primary or brand color.

To me, it’s no contest. If I had to create many variations of the example below:

function MyComponent() {
  const isMd = useBreakpoint("md");
  return (
    <MyButton size={isMd ? "medium" : "small"}>
      Click me
    </MyButton>
  );
}

I’d much rather see:

function MyComponent() {
  return (
    <MyButton sizes={["btn-sm", "md:btn-lg"]}>
      Click me
    </MyButton>
  )
}

No pesky JS logic to get in the way of understanding.

Conclusion

We’ve seen a few options for how to create responsive components.

These 3 methods should allow one to tackle most situations.

I’m not advocating to avoid, at all costs, conditional rendering based on device info. Sometimes it is too convenient and the component in question is unimportant, out of your control, doesn’t show above the fold, or you pinky promise that it won’t be server rendered.

What I’m advocating for is creating components that responsibly render responsively (word play intended). And this ultimately means that CSS media queries are far more effective and resilient than their JS counterparts. What is old is new again.

Comments

If you'd like to leave a comment, please email [email protected]