Making React components responsive with just 2 functions

jepser

Jepser Bernardino

Posted on May 18, 2019

Making React components responsive with just 2 functions

A little bit of context

Recently, I joined Badi as a Frontend Engineer, while working on the main product I found that it uses Bootstrap layout components specifically Row, Col and Container, and those have a props interface that allows you to configure the components by plain properties or object styled properties for each media query available.

import { Col } from 'super-library'

// either like this
<Col md={5} offsetMd={1} />

// or this
<Col md={{ size: 5, offset: 1 }} />

The Frontend team just started working on the components library, which had tokens for the most used components, in my previous experience I found "Layout components" for spacing, alignment and arrangement really useful, so I created two components:

  • Spacer which was meant to manage spacing in units of 8px to increase design accuracy (inspired on my previous company's design system)
  • Flex which is a simple implementation around flex layout with some more sauce
const Spacer = ({
  top, left, bottom, right, children,
}) => {
  return (
    <Root
      top={ top }
      left={ left }
      bottom={ bottom }
      right={ right }
    >
      {children}
    </Root>
  );
};
const Root = styled.div`
$({
  top, left, right, bottom,
}) => css`
  margin-top: ${top ? unitizedPx(top) : null};
  margin-right: ${right ? unitizedPx(right) : null};
  margin-bottom: ${bottom ? unitizedPx(bottom) : null};
  margin-left: ${left ? unitizedPx(left) : null};
 `
`;

And we were happy, at this time we were moving from CSS modules to styled-components so instead of having the repeated flex and spacing styling properties we used descriptive components that let us be faster and have less detail overhead because the well-tested components were in charge of that.

The challenge

So far all good, until we had a list of elements that had different layout and spacing depending on the viewport. The fastest thing was to use our MatchMedia component, which uses render props pattern to show one or the other component on mount.

<MatchMedia query={BREAKPOINTS.md}>
  { matches => matches ? <UseThis /> : <UseOther /> }
</MatchMedia>

Since this component was only rendered after a call to the API, the flash of the wrong component wasn't an issue.

But we could do better.

Solution(ish)

Remember the Bootstrap approach? The team was already familiar with that interface and we already were using our enum of media queries, so why not tie this implementation into Spacer and Flex components?

So, the spacer would look something like this:

// for base usage
<Spacer bottom={2} left={2}>cool component here</Spacer>

// for responsive usage
<Spacer md={{ bottom: 2, left: 2 }} left={1}>cool component here</Spacer>

Easy no?

const Spacer = ({
  top, left, bottom, right, children, sm, md, sm, lg, xl,
}) => {
  return (
    <Root
      top={ top }
      left={ left }
      bottom={ bottom }
      right={ right }
      sm={sm}
      md={md}
      lg={lg}
      xl={xl}
    >
      {children}
    </Root>
  );
};
const baseStyles = ({
  top, left, right, bottom,
}) => css`
  margin-top: ${top ? unitizedPx(top) : null};
  margin-right: ${right ? unitizedPx(right) : null};
  margin-bottom: ${bottom ? unitizedPx(bottom) : null};
  margin-left: ${left ? unitizedPx(left) : null};
`;

export const Root = styled.div`
  ${
    ({
      top, left, right, bottom, sm, md, lg, xl
    }) => `
      ${baseStyles({ top, left, right, bottom })}
      ${sm && baseStyles(sm)}
      ${md && baseStyles(md)}
      ${lg && baseStyles(lg)}
      ${xl && baseStyles(xl)}
    `
  }
`;

And this will work if the passed props are correctly formatted. But again, we could do better.

The 2 functions

So the implementation above is too verbose and exposes the implementation of our media queries, what if we add another rule, and we have several responsive components, this doesn't scale well.

We knew that:

  • There will be base props, in the case of the Spacer, [top, right, bottom, left]
  • There will be media queries with the same shape that will allow more granular control, in our case, we have [sm, md, lg, xl]

We already use media templates utility from Trick and Tips on styled components repository.

So we have our rules like:

const Root = styled.div`
 //...
 ${mediaqueries.md`
  //specific rules for this break point
 `
`

Props validation

We needed to validate the shape of the prop for the responsive conditions so we can assure that we have the expected behaviour, this without adding any dependency, so I came up with this:

/**
 * Generate a valid structure for responsive configuration for a component
 * @param {object} props props received from the component
 * @param {array} baseProps list of props to be validated
 *
 * @returns a structured object with the props for each media query
 */
export const generateResponsiveProps = (props, baseProps) => {
  // from the breakpoints registered check which props exists
  const shapedPropsWithMq = Object.keys(BREAKPOINTS).reduce(
    (responsiveProps, mqName) => {
      const propsForMq = props[mqName];
      if (!propsForMq && typeof propsForMq !== 'object') return responsiveProps;

      // for the props that exists, prepare them with the correct shape
      const shapedProps = baseProps.reduce(
        (propList, prop) => ({
          ...propList,
          [prop]: propsForMq[prop],
        }),
        {}
      );

      return {
        ...responsiveProps,
        [mqName]: shapedProps,
      };
    },
    {}
  );

  return shapedPropsWithMq;
};

This will create an object of responsive props, with null values for the styled components. For example, the Spacer component needs top, right, bottom and right props:

const BASE_PROPS = ['top', 'right', 'bottom', 'left']

// with this component:
<Spacer sm={{ bottom: 1, top: 2 }} md={{ bottom: 2, top: 1 }} sl={{ top: 1 }} />

const responsiveProps = generateResponsiveProps(props, BASE_PROPS)

// will generate this and remove sl because that's not in my media queries

{
  sm: {
    bottom: 1,
    top: 2,
    left: null,
    right: null
  },
  md: {
    bottom: 2,
    top: 1,
    left: null,
    right: null
  }
}

This is useful for the rules that will be passed to the styled component.

Responsive styling

Now that the props are correctly shaped, the next thing will be applying those to the component, for that, I created a helper function that receives the styles function and returns the styling the given props, the styles for each breakpoint defined.

import { css } from 'styled-components'

// this is what you should change if you have different breakpoints
const sizes = {
  giant: 1170,
  desktop: 992,
  tablet: 768,
  phone: 376,
}

// iterate through the sizes and create a media template
export const mediaqueries = Object.keys(sizes).reduce((accumulator, label) => {
  // use em in breakpoints to work properly cross-browser and support users
  // changing their browsers font-size: https://zellwk.com/blog/media-query-units/
  const emSize = sizes[label] / 16
  accumulator[label] = (...args) => css`
    @media (max-width: ${emSize}em) {
      ${css(...args)};
    }
  `
  return accumulator
}, {})
import { mediaqueries } from '../my-path-to-mq'

/**
 * Call the styles factory for with the correct props for each media query
 * @param {function} stylesGenerator function that generates the styles
 *
 * @returns {array} array of styles to be applied for the registered media queries
 */
export const generateResponsiveStyles = stylesGenerator => props =>
  Object.keys(mediaqueries).reduce((rules, mq) => {
    if (!props[mq]) return rules;

    const styles = mediaqueries[mq]`
    ${stylesGenerator(props[mq])}
  `;
    return [...rules, styles];
  }, []);

So finally, the way the styled component will look like this:

// define the styling function
const baseStyles = ({ top, left, right, bottom }) => css`
  margin-top: ${top ? unitizedPx(top) : null};
  margin-right: ${right ? unitizedPx(right) : null};
  margin-bottom: ${bottom ? unitizedPx(bottom) : null};
  margin-left: ${left ? unitizedPx(left) : null};
`;

// define the styling function for the responsive props
const responsiveStyles = generateResponsiveStyles(baseStyles);

export const Root = styled.div`
  ${baseStyles} // the base styles
  ${responsiveStyles} // the responsive styles
}
`;

This allowed us to separate the possible breakpoints from the implementation of the styles for the component, making it flexible to implement to other components and easy to maintain since the media queries declaration is manage separated from the styling ones.

You can find the code example in this codesanbox.

Happy coding. ☕️

💖 💪 🙅 🚩
jepser
Jepser Bernardino

Posted on May 18, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

NextJs makes coding fun
undefined NextJs makes coding fun

October 22, 2024

Best CSS Frameworks to Use in React.js
tailwindcss Best CSS Frameworks to Use in React.js

October 6, 2024