Flexible React Components at Any Scale

I’ve been working with React components for about the last 3 years now, with a variety of different state-management conventions being employed, from internal state to object literals to Backbone models to Redux. One of the things I enjoy the most about React is the simplicity of swapping out templates based on whatever logic you want to use. You can have functions that are conditionally called and output template fragments, you can inline your logic as part of a template itself, or you can have entirely different components to render your output conditionally, based on some logic determined by a parent component.

Here are some examples:

const Panel = ({ children }) => (
  <div className="panel">{children}</div>
);
const Well = ({ children }) => (
  <div className="well">{children}</div>
);

const MyComponent = ({ children, readOnly }) => {
  const Component = (readOnly ? Well : Panel);

  return <Component>{children}</Component>;
};
const Greeting = class extends React.Component {
  _renderLoggedInGreeting () {
    return <div>Welcome, {this.props.user.username}!</div>;
  }

  _renderLoggedOutGreeting () {
    return (
      <div>
        Welcome! Would you care to <a href="/login">log in?</a>
      </div>
    };
  }

  render () {
    return (
      this.props.user
      ? this._renderLoggedInContent()
      : this._renderLoggedOutContent()
    );
  }
};

One of the things I experimented with pretty early on was the idea of javascript-responsive layouts using React components. This allowed me to swap in a completely different physical layout when different rules were matched. Responsive CSS methods can get us part of the way, but often require adding in enough markup to make the application render properly at any scale, and then showing and hiding certain elements based on which media queries match your current device type, leading to a lot of hidden cruft in the DOM. Ideally, these bits should be removed completely when they’re not applicable, which is something React is great at managing, in the form of physically different layout components. I was primarily concerned with width constraints for different device form factors, which meant I could get away with using a simple resize event listener, like so:

import { debounce } from 'lodash';
import DesktopLayout from './layouts/desktop';
import MobileLayout from './layouts/mobile';
import TabletLayout from './layouts/tablet';

const minWidths = {
  desktop: 936,
  tablet: 768,
};

const ResponsiveLayout = class extends React.Component {
  state = {
    layout: DesktopLayout,
  };

  componentDidMount () {
    window.addEventListener('resize', this._resize);
    this._resize();
  }

  componentWillUnmount () {
    window.removeEventListener('resize', this._resize);
  }

  _resize = debounce(() => {
    let layout = MobileLayout;

    if (document.body.clientWidth >= minWidths.desktop) {
      layout = DesktopLayout;
    } else if (document.body.clientWidth >= minWidths.tablet) {
      layout = TabletLayout;
    }

    if (this.state.layout !== layout) {
      this.setState({
        layout,
      });
    }
  }, 250);

  render () {
    const { layout: Layout } = this.state;

    return <Layout />;
  }
};

The above technique can be performed at any layer of the application, so that you can have components that don’t necessarily check the size of the body width, but rather the maxWidth of their parentNode, to see how much space they can conceivably use. This way, you can have something like a data grid that might display 10 columns worth of data on a widescreen monitor, but on a tablet, may potentially cut that back to only 5 columns worth of the most relevant data, and on a smartphone, may render itself as a single-column of tiles that have the most important information you’re trying to display. If you were to then take that component on a widescreen layout, and shove it into the left column of your page, such that its usable space is no more than a typical smartphone would be, you would still get the smartphone view of that component. This allows us to use a combination of CSS responsive styles to flex within our given container, and javascript responsive layouts to physically change the DOM structure at different breakpoints.

The Problem

The above method has a couple of severe drawbacks, did you spot them already?

The first, and most important drawback is that we’re importing all 3 layout files into this view, before deciding which one to render. That means that no matter which one is being rendered, and whether that stays constant throughout the life of this application, all of the code that makes up the other 2 views (and the entirety of their dependency trees) is being loaded within the client. Since we’ve made no mention of chunking yet either, we can assume that all of that bundled functionality is being loaded up front by the client, which means a longer time to first render.

The second drawback here is that, as mentioned earlier, we’re only concerning ourselves with the width of the client in this example. We could also pretty easily get the height with very little additional code here, but we don’t have access to the bulk of what CSS media queries would offer when using a simple resize event listener. In addition, if a person on a desktop computer is dragging the edges of their window in and out, you’ll get constant firing of that resize event listener. Now, because we’re using lodash’s debounce() function, most of those event firings will be suppressed, but there are still a lot of events being triggered.

The Goal

Overcome both of these hurdles, in order to provide maximum flexibility for determining how and when to render our components, along with lazy-loading any additional layout components only when they’re needed.

Step 1: Webpack!

Lazy loading components is a fairly trivial thing to do these days, thanks to utilities like Webpack. There are a few different patterns already published about chunking and conditionally loading your component code. One of my favorites comes from this article about asynchronously loading React components with Webpack. It uses Promises, so that the require.ensure() call is only executed once. If you couple that with ES7 async/await syntax, you can even avoid the ugly promise-looking code within your components.

A quick and dirty example looks something like this:

let promise;

const LazyLoadedComponentWrapper = () => {
  if (!promise) {
    promise = new Promise((resolve) => {
      require.ensure([], (req) => {
        resolve(req('components/lazy-loaded-component'));
      });
    });
  }
  
  return promise;
};

export default LazyLoadedComponentWrapper;

Starting with an undefined promise variable, we initialize it the first time this module is loaded and invoked, resolving with a separate code chunk that Webpack creates for us by the use of require.ensure(). All subsequent calls to this function will simply return the resolved Promise with the lazy-loaded component module. If we revisit the previous example using this method around our layout components, our generalized layout component would look something more like this:

import { debounce } from 'lodash';
import DesktopLayout from './layouts/desktop';
import MobileLayout from './layouts/mobile';
import TabletLayout from './layouts/tablet';

const minWidths = {
  desktop: 936,
  tablet: 768,
};

const ResponsiveLayout = class extends React.Component {
  state = {
    layout: null,
  };

  componentDidMount () {
    window.addEventListener('resize', this._resize);
    this._resize();
  }

  componentWillUnmount () {
    window.removeEventListener('resize', this._resize);
  }

  _resize = debounce(() => {
    let layout = MobileLayout;

    if (document.body.clientWidth >= minWidths.desktop) {
      layout = DesktopLayout;
    } else if (document.body.clientWidth >= minWidths.tablet) {
      layout = TabletLayout;
    }

    layout().then((component) => {
      if (this.state.layout !== component) {
        this.setState({
          layout: component,
        });
      }
    });
  });

  render () {
    const { layout: Layout } = this.state;

    if (Layout) {
      return <Layout />;
    } else {
      return null; // Or some kind of loading spinner
    }
  }
};

Not a huge change from an implementation perspective, but it could make a huge difference in the initial load time, because now each of those 3 modules being imported is just a single function that does nothing until it’s invoked, and only at that time does it load the chunk containing its component definition, and any associated dependencies.

Step 2: matchMedia!

Now to handle a more fully-featured implementation of media queries within our components, we can use a combination of window.matchMedia() and its associated addListener() and removeListener() functions. Let’s modify the above component a little more to get rid of our resize listener altogether, and replace it with some matchMedia() queries and listeners to be called when those media queries are triggered. I’ll also take the opportunity to swap in some async/await syntax in place of the promise chain, just for demonstration purposes.

import DesktopLayout from './layouts/desktop';
import MobileLayout from './layouts/mobile';
import TabletLayout from './layouts/tablet';

const width = {
  desktop: 936,
  tablet: 768,
};

const layouts = {
  desktop: {
    layout: DesktopLayout,
    mql: window.matchMedia(
      `(min-width: ${widths.desktop}px)`
    ),
  },
  mobile: {
    layout: MobileLayout,
    mql: window.matchMedia(
      `(min-width: ${widths.tablet}px) and (max-width: ${widths.desktop - 1}px)`
    ),
  }
  tablet: {
    layout: TabletLayout,
    mql: window.matchMedia(
      `(max-width: ${widths.tablet - 1}px)`
    ),
  },
};

const handlerFn = async function (layout, mql) {
  if (mql.matches) {
    this.setState({
      layout: await layout(),
    });
  }
};

const ResponsiveLayout = class extends React.Component {
  _listeners = {};
  
  state = {
    layout: null,
  };

  componentDidMount () {
    this._registerListeners();
  }

  componentWillUnmount () {
    this._unregisterListeners();
  }

  _registerListeners () {
    Object.keys(layouts).forEach((key) => {
      const handler = handlerFn.bind(this, layouts[key].layout);
      // So we can unregister later, always clean up your mess!
      this._listeners[key] = handler;
      // Trigger an initial check to populate our state variable
      handler(layouts[key].mql);
      // Add our listener to be called with this media query triggers
      layouts[key].mql.addListener(handler);
    });
  }

  _unregisterListeners () {
    Object.keys(this._listeners).forEach((key) => {
      layouts[key].mql.removeListener(this._listeners[key]);
    });
  }

  render () {
    const { layout: Layout } = this.state;

    if (Layout) {
      return <Layout />;
    } else {
      return null; // Or some kind of loading spinner
    }
  }
};

It takes a few more lines of code to do it this way, because we’re defining media queries and layout components in an object at the top, and constructing instance-level handlers inside our component. However, any additional media queries we want to monitor can simply be added to the object literal at the top of the file, and they’ll be automatically handled by the existing _registerListeners() and _unregisterListeners() methods. We could even externalize that object into a separate file to reduce the risk of introducing bugs into this component, which is always a maintenance concern, when adding and removing media queries in the future.

Using the matchMedia() function, we can input any kind of media query we want to monitor, whether it has to do with height and width, device type, orientation, or anything else that can be tracked. Those handler functions are then alerted when the media queries themselves are triggered, as opposed to every pixel of movement in a resize listener, resulting in much less event traffic within the client. Granted, this method will only work for page-level considerations. Media query rules don’t apply at the element level, so we can’t determine specifically how much space this component itself has available to it, like in the example of sticking a data grid into a skinny column and getting it to render in a mobile format.

The idea of element queries has been kicked around for a while now. If they ever make it into the spec, and are supported by matchMedia(), it should be easy enough to get them into a setup like this also. In the meantime, though, if you wanted to handle situations like that, you’d have to use an approach like a resize event listener.

Conclusion

So what have we learned? By combining a few different techniques, we can create React components that can be intelligent enough to determine which layout to render in different circumstances, across a variety of form factors, both at the page level and the container level. By chunking our code properly with Webpack, we can also slim down the size of our deployed bundles, and make sure that we’re only loading additional layout components as needed, based on device orientation, window resizing, or anything else that may change media query information. Combining these techniques with traditional CSS media queries, we can create flexible components that will both look appropriate and offer appropriate interactions, including physically different layouts, at any given scale, without blowing out application load times, and without unnecessarily bloating the DOM with hidden element cruft.

Leave a Reply

Your email address will not be published. Required fields are marked *