People working on laptops

Lightning Web Components Adaptive Design Utility Component

by Nikita Verkhoshintcev

Reusability is the primary principle of front-end component architecture, and Lightning Web Components is no exception.

Sometimes, projects are hectic and require organizations to expedite their delivery.

In such circumstances, it's tempting to create technical debt to move fast because that's the business objective.

I've experienced it firsthand and have observed it in numerous implementations.

It's easy to duplicate a functionality rather than creating a shared service or utility component.

One everyday use case I've noticed is rendering different component templates for mobile and desktop.

So-called adaptive design.

The key difference between responsive and adaptive designs is that responsive design is fluid, whereas adaptive design has specific breakpoints that display the layout that best fits the current state.

A classic example from an accessibility point of view is rendering the data table on the desktop.

For example, since tables often have poor accessibility on mobile devices, you may want to render them as an item list instead.

It's an often-used pattern, and to ensure reusability, it's a perfect candidate to handle via a shared utility.

In this post, I aim to demonstrate how to create a utility class and implement functions like NavigationMixin to extend JavaScript classes.

Controller

Let's determine what we need to implement to render different templates based on the current viewport resolution.

We can benefit from the web component's render function to achieve that.

The idea is to create two templates (possibly more), and based on the condition in the render function, use one of those.

We will also need to subscribe to the window resize event to know when the viewport changes.

Note: The resize operation can be triggered frequently, which could lead to poor performance.

It appears that modern browsers already debounce such events.

However, it's still a good practice to implement throttling and debouncing for these listeners.

Thankfully, we now have the MediaQueryList widely available, so we don't have to listen for window resize events and can instead track media query changes.

The main benefit is that such an event triggers only once, when the viewport size changes, thus it's much more efficient.

desktopMediaQuery = window.matchMedia('(min-width: 600px)');

isDesktop = this.desktopMediaQuery.matches;

handleMediaQueryChange(e) {
  this.isDesktop = e.matches;
}

Do not forget that, since we added a global listener, we need to detach the event handler in the disconnected callback when we no longer need it.

Note: The Lightning Web Component framework doesn't automatically detach global events, and you must clean them up in the disconnected callback.

import mobile from './templates/mobile.html';
import desktop from './templates/desktop.html';

class AdaptiveComponent {
  desktopMediaQuery = window.matchMedia('(min-width: 600px)');

  isDesktop = this.desktopMediaQuery.matches;

  handleMediaQueryChange(e) {
    this.isDesktop = e.matches;
  }

  connectedCallback() {
    this.handleMediaQueryChange = this.handleMediaQueryChange.bind(this);
    this.desktopMediaQuery.addEventListener('change', this.handleMediaQueryChange);
  }

  disconnectedCallback() {
    this.desktopMediaQuery.removeEventListener('change', this.handleMediaQueryChange);
  }

  render() {
    return this.isDesktop ? desktop : mobile;
  }
}

As you can see, every component that wants to have a similar functionality would have to implement a lot of boilerplate code.

We could avoid that and manage it in one place if we create another class to handle the observing and rendering.

If the company decides to change its breakpoints, you could configure it in just one place.

Then, in each component that wants to implement such functionality, they would have to extend that class and add their own mobile and desktop templates.

JavaScript Class Extension

First of all, in JavaScript, you cannot have a list of classes to extend, so since we have to extend the LightningElement by default, we cannot just append another class.

Fortunately, in JavaScript, everything except the primitive values is an Object.

A function is also an object, and a class is just syntactic sugar around the function.

It means that since we can use functions and return other functions, we can have a higher-order function that returns a new, extended class.

The idea is the same as with the NavigationMixin.

We have a function that passes inside the LightningElement class and returns a new, extended version of it.

function AdaptiveComponent(base) {
  class AdaptiveComponent extends base {
    constructor() {
      super();
    }
  }

  return AdaptiveComponent;
}

Note: You can add as many class extensions as needed with that approach.

I also want to make it more flexible and add configuration options to the higher-order function.

For example, we want developers to be able to pass their templates for mobile and desktop and set the necessary media queries, while still having a default one.

Here is a final version of the function.

export default function AdaptiveComponent(base, options) {
  class AdaptiveComponent extends base {
    desktopMediaQuery = window.matchMedia(options?.mediaQuery ?? '(min-width: 600px)');

    isDesktop = this.desktopMediaQuery.matches;

    handleMediaQueryChange(e) {
      this.isDesktop = e.matches;
    }

    constructor() {
      super();
    }

    connectedCallback() {
      this.handleMediaQueryChange = this.handleMediaQueryChange.bind(this);
      this.desktopMediaQuery.addEventListener('change', this.handleMediaQueryChange);
    }

    disconnectedCallback() {
      this.desktopMediaQuery.removeEventListener('change', this.handleMediaQueryChange);
    }

    render() {
      return this.isDesktop ? options.templates.desktop : options.templates.mobile;
    }
  }

  return AdaptiveComponent;
}

Implementation

Once we have our utility function, let's implement it and create a data table component that renders rows as card elements on mobile resolution.

I've taken a sample datatable component from the Lightning Component library and added a mobile template that renders rows in a loop.

We want to import the Lightning Element, Adaptive Component, and both templates.

import { LightningElement } from 'lwc';
import mobile from './templates/mobile.html';
import desktop from './templates/desktop.html';
import AdaptiveComponent from 'c/adaptiveComponent';

Now, we need to extend our class with the AdaptiveComponent and provide the LightningElement and templates to its options.

export default class Datatable extends AdaptiveComponent(LightningElement, {
  templates: {
    mobile,
    desktop
 },
  mediaQuery: '(min-width: 600px)'
}) {}

An important note is that, since we want to use a default connected callback to generate the data in the given scenario, we also need to call the connected callback from the parent component using the super keyword.

Otherwise, you will overwrite the parent's implementation of the callback where we set up the listener.

connectedCallback() {
  super.connectedCallback(); // Call the parent class's connectedCallback method to prevent overriding its functionality
  this.data = generateData({ amountOfRecords: 100 });
}

Here is the final version of the data table component, which implements the adaptive component and adds adaptive capabilities to Lightning Web Components.

import { LightningElement } from 'lwc';
import mobile from './templates/mobile.html';
import desktop from './templates/desktop.html';
import AdaptiveComponent from 'c/adaptiveComponent';
import generateData from './generateData';

const columns = [
    { label: 'Label', fieldName: 'name' },
    { label: 'Website', fieldName: 'website', type: 'url' },
    { label: 'Phone', fieldName: 'phone', type: 'phone' },
    { label: 'Balance', fieldName: 'amount', type: 'currency' },
    { label: 'CloseAt', fieldName: 'closeAt', type: 'date' },
];

export default class Datatable extends AdaptiveComponent(LightningElement, {
  templates: {
    mobile,
    desktop
  },
  mediaQuery: '(min-width: 600px)'
}) {
  data = [];
  columns = columns;

  connectedCallback() {
      super.connectedCallback(); // Call the parent class's connectedCallback method to prevent overriding its functionality
      this.data = generateData({ amountOfRecords: 100 });
  }
}
<!-- desktop.html -->
<template>
  <div style="height: 300px;">
    <lightning-datatable
      key-field="id"
      data={data}
      columns={columns}>
    </lightning-datatable>
  </div>
</template>
<!-- mobile.html -->
<template>
  <lightning-layout pull-to-boundary="small" multiple-rows>
    <template for:each={data} for:item="row">
      <lightning-layout-item key={row.name} size="12" padding="around-small">
        <lightning-card variant="Narrow" title={row.name} icon-name="standard:account">
          <ul class="slds-var-p-horizontal_small">
            <li>
              <lightning-formatted-url value={row.website} target="_blank"></lightning-formatted-url>
            </li>
            <li>
              <lightning-formatted-number value={row.amount} format-style="currency" currency-code="EUR"></lightning-formatted-number>
            </li>
            <li>
              <lightning-formatted-phone value={row.phone}></lightning-formatted-phone>
            </li>
            <li>
              <lightning-formatted-date-time value={row.closeAt}></lightning-formatted-date-time>
            </li>
          </ul>
        </lightning-card>
      </lightning-layout-item>
    </template>
  </lightning-layout>
</template>

You can also find the source code and an example data table implementation on GitHub.

Conclusion

In this post, I've touched upon a standard use case for implementing adaptive Lightning Web Components.

As reusability is the primary principle of component architecture and DRY (Don't Repeat Yourself) is a general principle in software engineering, we aim to implement a shared utility to manage adaptive rendering and reduce boilerplate code.

I've demonstrated how to create a higher-order utility function to support multiple class extensions in JavaScript, how to use the MediaQueryList to track breakpoint changes, and how to use multiple templates in LWCs.

You can apply these principles to other areas as well.

The short demo is also available here.

Nikita Verkhoshintcev photo

Nikita Verkhoshintcev

Senior Salesforce Technical Architect & Developer

I'm a senior Salesforce technical architect and developer, specializing in Experience Cloud, managed packages, and custom implementations with AWS and Heroku. I have extensive front-end engineering experience and have worked as an independent contractor since 2016. My goal is to build highly interactive, efficient, and reliable systems within the Salesforce platform. Typically, companies contact me when a complex implementation is required. I'm always open to collaboration, so please don't hesitate to reach out!

Let's work together!

Do you have a challenge or goal you'd like to discuss? We offer a free strategy call. No strings attached, just a way to get to know each other.

Book a free strategy call

Stay updated

Subscribe to our newsletter to get Salesforce tips to your inbox.

No spam, we promise!