DEV Community

Justin
Justin

Posted on • Edited on

4 Patterns for Responsive Props in React

Table of Contents

Responsive CSS

Many solutions exist for writing responsive CSS in React.

If you feel like you need to use one, I recommend choosing something which supports reusable media queries. You don't want to be hard-coding breakpoint values all throughout your code (it's tedious to write, error prone, and difficult to change).

But you might not need to write any responsive CSS.

Responsive Props

Responsive CSS in React has a glaring weakness: it doesn't allow you to responsively change the values of props. Instead of writing CSS wrapped in media queries, responsive props is a method where you specify the value of the prop for predefined breakpoints and logic exists somewhere that chooses the value associated with the active breakpoint.

Why would you want to do that? Let's say you have different button sizes: large, medium, small. You probably don't want all buttons to change size responsively the same way. You might want one button to be small in mobile layouts while another is medium. So instead of responsive CSS, what you really want is responsive props.

We'll introduce some patterns below which primarily differ in two ways:

  1. How the different prop values are specified
  2. Where the logic exists for choosing the correct value

The code samples shown in this blog post are copied from a sample app that has working examples of all the patterns.

Responsive Prop Patterns

The foundation of responsive props in react is knowing whether a given media query (aka breakpoint) is active. Let's write a custom hook for that called useMediaQuery().

import { useEffect, useState } from "react";

/**
 * Custom hook that tells you whether a given media query is active.
 *
 * Inspired by https://usehooks.com/useMedia/
 * https://gist.github.com/gragland/ed8cac563f5df71d78f4a1fefa8c5633
 */
export default function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);
  useEffect(
    () => {
      const mediaQuery = window.matchMedia(query);
      setMatches(mediaQuery.matches);
      const handler = (event) => setMatches(event.matches);
      mediaQuery.addEventListener("change", handler);
      return () => mediaQuery.removeEventListener("change", handler);
    },
    [] // Empty array ensures effect is only run on mount and unmount
  );
  return matches;
}
Enter fullscreen mode Exit fullscreen mode

It can be used like this:

const isActive = useMediaQuery('(max-width: 640px)');
Enter fullscreen mode Exit fullscreen mode

But remember we don't want to be littering our code with breakpoint values so let's create another hook which returns booleans for all of our defined breakpoints. We'll call it useBreakpoints().

import useMediaQuery from "./useMediaQuery";

/**
 * Get a set of boolean representing which breakpoint is active
 * and which breakpoints are inactive.
 *
 * Inspired by: https://github.com/contra/react-responsive/issues/162#issuecomment-592082035
 */
export default function useBreakpoints() {
  const breakpoints = {
    isXs: useMediaQuery("(max-width: 640px)"),
    isSm: useMediaQuery("(min-width: 641px) and (max-width: 768px)"),
    isMd: useMediaQuery("(min-width: 769px) and (max-width: 1024px)"),
    isLg: useMediaQuery("(min-width: 1025px)"),
    active: "xs"
  };
  if (breakpoints.isXs) breakpoints.active = "xs";
  if (breakpoints.isSm) breakpoints.active = "sm";
  if (breakpoints.isMd) breakpoints.active = "md";
  if (breakpoints.isLg) breakpoints.active = "lg";
  return breakpoints;
}
Enter fullscreen mode Exit fullscreen mode

It's used like this:

const {isXs, isSm, isMd, isLg, active} = useBreakpoints();
Enter fullscreen mode Exit fullscreen mode

Those hooks can power all of the responsive prop patterns discussed below.

Conditional Rendering

Conditional rendering is the pattern of specifying content that gets rendered at the different breakpoints. We can accomplish that in two different ways.

Conditional Rendering with a Hook

We can use the useBreakpoints() hook from above to do conditional rendering like this:

const {isXs, isSm} = useBreakpoints();
return isXs || isSm ? <Button size="small" /> : <Button />; 
Enter fullscreen mode Exit fullscreen mode

Conditional Rendering with a Component

We could also write a component which will do something similar for us. Let's call it <Breakpoint>.

import useBreakpoints from "./useBreakpoints";

export default function Breakpoint({ at, children }) {
  if (!at) {
    console.error("<Breakpoint>: must specify a breakpoint for the `at` prop.");
  }
  const { active } = useBreakpoints();
  return active === at ? children : null;
}
Enter fullscreen mode Exit fullscreen mode

Then we can use it like this:

return (<>
  <Breakpoint at="xs">
    <Button size="small">Button</Button>
  </Breakpoint>
  <Breakpoint at="sm">
    <Button size="small">Button</Button>
  </Breakpoint>
  <Breakpoint at="md">
    <Button>Button</Button>
  </Breakpoint>
  <Breakpoint at="lg">
    <Button size="large">Button</Button>
  </Breakpoint>
</>);
Enter fullscreen mode Exit fullscreen mode

In its naive form, the component version of this pattern can be quite verbose. fresnel is a library which uses this pattern and provides additional props such as greaterThan and between which can decrease the amount of code you need to write.

Notice that with conditional rendering, we're not changing the value of props so much as changing what gets rendered. There are situations where that's exactly what we need, such as choosing whether to render the mobile or desktop menu.

return isXs || isXm ? <Mobile /> : <Desktop />;
Enter fullscreen mode Exit fullscreen mode

As shown with the button example above, the conditional rendering pattern doesn't fit as well when we just want to make small tweaks such as changing the size or position of components. For those situations, we have other patterns that just modify props.

Breakpoint Props

Maybe we could have one prop for each breakpoint. So instead of just size we have sizeXs, sizeSm and so on. It would be used like this:

<Button sizeXs="small" sizeSm="small" sizeMd="medium" sizeLg="large">Button</Button>
Enter fullscreen mode Exit fullscreen mode

In terms of usage, that's quite a bit less verbose than the example for conditional rendering. What about the implementation?

In the naive form, the implementation of this is very verbose.

import styles from "../Button.module.css";
import useBreakpoints from "../useBreakpoints";

const defaultSize = "";
const defaultColor = "#eee";

export default function ButtonNaive({
  sizeXs,
  sizeSm,
  sizeMd,
  sizeLg,
  colorXs,
  colorSm,
  colorMd,
  colorLg,
  children
}) {
  const { isXs, isSm, isMd, isLg } = useBreakpoints();
  let activeSize = defaultSize;
  let activeColor = defaultColor;
  if (isXs) {
    activeSize = sizeXs;
    activeColor = colorXs;
  } else if (isSm) {
    activeSize = sizeSm;
    activeColor = colorSm;
  } else if (isMd) {
    activeSize = sizeMd;
    activeColor = colorMd;
  } else if (isLg) {
    activeSize = sizeLg;
    activeColor = colorLg;
  }
  const buttonClasses = [styles.base];
  if (styles[activeSize]) {
    buttonClasses.push(styles[activeSize]);
  }
  return (
    <button
      className={buttonClasses.join(" ")}
      style={{ backgroundColor: activeColor }}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can do much better with a dynamic lookup for the props.

import styles from "../Button.module.css";
import useBreakpoints from "../useBreakpoints";

const defaultSize = "";
const defaultColor = "#eee";

export default function DynamicButton({ children, ...props }) {
  const { active } = useBreakpoints();
  // The active breakpoint comes out lowercase but for the props
  // the first letter of the breakpoint needs to be capitalized.
  const activeCapitalized = active[0].toUpperCase() + active[1];
  // Now we dynamically lookup the value of each responsive prop
  // according to the active breakpoint.
  const activeSize = props[`size${activeCapitalized}`] || defaultSize;
  const activeColor = props[`color${activeCapitalized}`] || defaultColor;
  const buttonClasses = [styles.base];
  if (styles[activeSize]) {
    buttonClasses.push(styles[activeSize]);
  }
  return (
    <button
      className={buttonClasses.join(" ")}
      style={{ backgroundColor: activeColor }}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's a little better but there's still more logic than we'd like to have in our components, so let's move some of it into a hook.

const defaultSize = "";
const defaultColor = "#eee";

function useResponsiveProp(props, propName, defaultValue) {
  const { active } = useBreakpoints();
  const activeCapitalized = active[0].toUpperCase() + active[1];
  return props[`${propName}${activeCapitalized}`] || defaultValue;
}

export default function DynamicButton({ children, ...props }) {
  const activeSize = useResponsiveProp(props, 'size', defaultSize);
  const activeColor = useResponsiveProp(props, 'color', defaultColor);
  const buttonClasses = [styles.base];
  if (styles[activeSize]) {
    buttonClasses.push(styles[activeSize]);
  }
  return (
    <button
      className={buttonClasses.join(" ")}
      style={{ backgroundColor: activeColor }}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you still think that's too much logic for dumb presentation components then you could also create an HOC.

export default MakeResponsive(Button, ["size", "color"]);

function MakeResponsive(WrappedComponent, responsiveProps = []) {
  function MakeResponsiveWrapper(props) {
    const { active } = useBreakpoints();
    const activeCapitalized = active[0].toUpperCase() + active[1];
    const modifiedProps = { ...props };
    // Process the responsive props to extract responsive values
    for (const prop of responsiveProps) {
      const breakpointProp = `${prop}${activeCapitalized}`;
      if (props[breakpointProp]) {
        modifiedProps[prop] = props[breakpointProp];
      }
    }
    return <WrappedComponent {...modifiedProps} />;
  }
  MakeResponsiveWrapper.displayName = `MakeResponsive(${
    WrappedComponent.displayName || WrappedComponent.name
  })`;
  return MakeResponsiveWrapper;
}
Enter fullscreen mode Exit fullscreen mode

That lets our components be dumb again, but now we have an HOC ๐Ÿ˜ฌ.

Object of Props

The same code which inspired the useMediaQuery() hook also introduced me to a new pattern: specifying values for each breakpoint and letting a hook choose those values.

useBreakpointValues()

We can use the useBreakpoints() hook to create another hook called useBreakpointValues() which accepts a map of breakpoints and values and returns the value for the breakpoint that is currently active.

function useBreakpointValues(breakpointValues) {
  const { active } = useBreakpoints();
  return breakpointValues[active];
}
Enter fullscreen mode Exit fullscreen mode

We could use that inside our components to make any prop accept responsive values.

const defaultColors = {
  xs: "#eee",
  sm: "#eee",
  md: "#eee",
  lg: "#eee"
};

export default function Button({ size, color = defaultColors, children }) {
  const appliedSize = useBreakpointValues(size);
  const appliedColor = useBreakpointValues(color);
  const buttonClasses = [styles.base];
  if (styles[appliedSize]) {
    buttonClasses.push(styles[appliedSize]);
  }
  return (
    <button
      className={buttonClasses.join(" ")}
      style={{ backgroundColor: appliedColor }}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

But that makes our component more complicated. I recommend keeping the component simple and using the hook outside of the component.

const currentSize = useBreakpointValues({
  xs: 'small',
  sm: 'small',
  md: 'medium',
  lg: 'large'
});
return <Button size={currentSize}>Button</Button>;
Enter fullscreen mode Exit fullscreen mode

Using this pattern, our components can remain dumb presentation components by moving the breakpoint logic into a custom hook that we use outside of the components.

Component

We could also build a component version of useBreakpointValues() which uses render props to create a responsive prop pattern I learned from instructure-ui.

import useBreakpointValues from "./useBreakpointValues";

export default function Responsive({ props, children, render }) {
  const appliedProps = useBreakpointValues(props);
  if (!(children || render)) {
    console.error("<Responsive> must be given a render prop or children prop.");
  }
  const renderFn = children || render;
  return renderFn(appliedProps);
}
Enter fullscreen mode Exit fullscreen mode

We would use it like this:

return (
  <Responsive props={{
    xs: 'small',
    sm: 'small',
    md: 'medium',
    lg: 'large'
  }}>
    {(size) => <Button size={size}>Button</Button>}
  </Responsive>
);
Enter fullscreen mode Exit fullscreen mode

Why would you want to do that instead of the hook? Perhaps just personal preference. And when you have responsive props for multiple components, it could help avoid the need for coming up with unique names. You can see this in the following contrived example using different button sizes for three buttons. Using the <Responsive> component is a little more verbose, and the render props pattern is pretty awkward to type, but maybe you just really dislike coming up with creative variable names.

const size1 = useBreakpointValues({...});
const size2 = useBreakpointValues({...});
const size3 = useBreakpointValues({...});
return (
  <div>
    <Button size={size1}>Button 1</Button>
    <Button size={size2}>Button 2</Button>
    <Button size={size3}>Button 3</Button>
  </div>
);

// Here's the same setup using <Responsive>
return (
  <div>
    <Responsive props={{...}}>
      {(size) => <Button size={size}>Button 1</Button>}
    </Responsive>
    <Responsive props={{...}}>
      {(size) => <Button size={size}>Button 2</Button>}
    </Responsive>
    <Responsive props={{...}}>
      {(size) => <Button size={size}>Button 3</Button>}
    </Responsive>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

HOC for Responsive Props

Maybe neither of those are quite what you're looking for because you really like HOCs. We can do that too.

Usage:

<ButtonHOC size={{ xs: "small", sm: "small", md: "medium", lg: "large" }}>
  Button
</ButtonHOC>
Enter fullscreen mode Exit fullscreen mode

Implementation:

function MakeResponsive(WrappedComponent, responsiveProps = []) {
  function MakeResponsiveWrapper(props) {
    const { active } = useBreakpoints();
    const modifiedProps = { ...props };
    // Process the responsive props to extract responsive values
    for (const prop of responsiveProps) {
      if (props[prop]) {
        modifiedProps[prop] = props[prop][active];
      }
    }
    return <WrappedComponent {...modifiedProps} />;
  }
  MakeResponsiveWrapper.displayName = `MakeResponsive(${
    WrappedComponent.displayName || WrappedComponent.name
  })`;
  return MakeResponsiveWrapper;
}

const ButtonHOC = MakeResponsive(Button, ['size','color']);
Enter fullscreen mode Exit fullscreen mode

Again, our component stays dumb while the HOC makes it smarter.

TIP: If you dislike having to specify a value for each breakpoint, even when each breakpoint doesn't have a distinct value, then you could add some logic to useBreakpointValues() so that if a breakpoint value isn't specified it just uses the value for the breakpoint below it. Then you would only have to specify when the value changes. Example: {xs: 'small', md: 'large'}.

Array of Props

If you like how the object props pattern moves the logic outside of the component but dislike having to specify the breakpoints each time by name, then you may like this variation where props are specified via an array.

const size = useBreakpointValues([ "small", "small", "medium", "large" ]);
return <Button size={size}>Button</Button>;

// Or...

<Button size={[ "small", "small", "medium", "large" ]}>
  Button
</Button>
Enter fullscreen mode Exit fullscreen mode

The downside of this pattern is that it's not explicit; e.g. it's not immediately clear which value is associated with which breakpoint.

A Note About SSR

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. Make this choice carefully because it could impact SEO (particularly for search engines that don't execute JavaScript).

Summary

A few patterns exist for responsive props in React. While choosing which patterns to use, consider these characteristics.

How props are specified Where the logic exists for choosing the active value
Conditional Rendering Separately in each instance Outside the component
Breakpoint Props Naive One prop for each breakpoint Inside the component
Dynamic One prop for each breakpoint Inside the component
Dynamic Hook One prop for each breakpoint Inside a hook
HOC One prop for each breakpoint Inside an HOC
Object of Props Hook An object In the component or in a hook
Render-prop Component An object In the render-prop component
HOC An object In the HOC
Array of Props Hook An array In the component or in a hook
Render-prop Component An array In the render-prop component
HOC An array In the HOC

My preference is to use the Object Props pattern with the useBreakpointValue() hook and <Responsive> component because I like the explicit nature of the props object and I like having the logic for choosing the active breakpoint value outside of my components.

What do you think? Which pattern do you like? Are there responsive prop patterns I didn't include? If you're not using responsive props, do you feel like you should? Let me know in the comments. Thanks for reading!


Responsive Prop Libraries

Conditional Rendering

Breakpoint Props

Object of Props

Array of props

Top comments (2)

Collapse
 
gabrielmlinassi profile image
Gabriel Linassi

So complicated topic.

Collapse
 
prince272 profile image
Prince Owusu • Edited

Good job!