Dynamic LWC Creation: Part Two
Posted: August 09, 2024

Dynamic LWC Creation: Part Two

Table of Contents:

  • Getting Started

  • Going Deeper

    • Separation Of Concerns
  • Packaging

  • Example Logger Component For Apex Rollup

  • Wrapping Up

When I wrote Dynamic LWC Creation, I had a lot of fun figuring out different ways to bypass the lack of on-platform support for dynamically generated Lightning Web Components. Now that dynamically generating components is GA, I thought it might be fun to revisit things to show off a few of the possible permutations when it comes to making use of this functionality. I’m going to discuss some architectural considerations to keep in mind, as well, and end with a demo of how using dynamic components can make your existing Lightning Web Components more resilient.

Getting Started

If you haven’t already, take a look at the docs for dynamic instantiation. Here’s the basic markup straight from the docs:

// in a file called lwcCreator
<template>
  <div class="container">
    <lwc:component lwc:is={componentConstructor}></lwc:component>
  </div>
</template>

In order for this to be deployed, the js-meta.xml for your component needs to have at least:

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>61.0</apiVersion>
    <isExposed>false</isExposed>
    <capabilities>
        <capability>lightning__dynamicComponent</capability>
    </capabilities>
</LightningComponentBundle>

Lastly, some relatively simple JavaScript:

import { LightningElement } from "lwc";
export default class extends LightningElement {
  componentConstructor;

  connectedCallback() {
    import("c/concreteComponent").then(
      ({ default: ctor }) => (this.componentConstructor = ctor),
    );
  }
}

And, provided that c/concreteComponent is a valid reference, this all works as-is. This is already a pretty big quality of life upgrade. But this is the Joys Of Apex!

Going Deeper

The docs currently say the following about the import call:

The import() call returns a promise that resolves to a LightningElement constructor.

But … in looking at the code snippet, I found myself a bit skeptical with the current wording. Traditionally, import (and it’s long-time colleague, require) would return an object with the entirety of an exported module. More to the point, if the example is destructuring the default property off of the return from an import call, I think it would be more accurate to say that the ctor property being shown off in the example is an instance of the LightningElement class being imported — because that’s the default export from every LWC class.

In order to test this hypothesis, it should be possible to access non-default exports from within that same promise:

import { LightningElement } from "lwc";
export default class extends LightningElement {
  componentConstructor;
  logger;

  async connectedCallback() {
    import("c/logger").then(async ({ createLogger }) => {
      this.logger = await createLogger();
      this.logger.debug("Nebula Logger online");
      this.logger.saveLog();
    });
  }
}

And, indeed, this works no problem. So that’s fun! But what if we wanted to take things a bit further? What if we wanted to dynamically initialize more than one component?

I started toying around with the simplest possible case — passing a comma-separated list of component names to initialize from outside our component:

// using Aura - gasp!
<c:lwcCreator componentNames="c/logger" />

And then with a bit of modification in the lwcCreator JavaScript controller:

import { api, LightningElement } from "lwc";

export default class LwcCreator extends LightningElement {
  @api
  componentNames;

  dynamicComponents = [];

  async connectedCallback() {
    // here I prefer a falsy check rather than
    // awaiting on an empty list
    this.dynamicComponents = !this.componentNames
      ? []
      : await Promise.all(
          this.componentNames.split(",").map((componentName) =>
            import(componentName)
              .then(({ default: lwcClass }) => ({
                componentConstructor: lwcClass,
                // if we were going to have more than one instance of the same component
                // we would need a better key
                key: componentName,
                props: {},
              }))
              .catch(() => ({
                componentConstructor: false,
              })),
          ),
        ).filter(({ componentConstructor }) => !!componentConstructor);
  }

  handleClick() {
    const logger = this.template.querySelector("c-logger");
    logger.debug("Clicked from button!");
    logger.saveLog();
  }
}

This feeds into the markup:

<template>
  <template for:each={dynamicComponents} for:item="dynamicComponent">
    <lwc:component
      lwc:is={dynamicComponent.componentConstructor}
      key={dynamicComponent.key}
    ></lwc:component>
  </template>

  <lightning-button
    onclick={handleClick}
    label="Click to Log"
  ></lightning-button>
</template>

Clicking on the button will, predictably, lead to a Nebula Logger log statement printing to my console:

The log message shows up!

That’s pretty cool. The snippet doesn’t really do it justice, since Nebula formats JavaScript console statements, but it should give you a general idea. The docs also have this to say:

Use renderedCallback on the parent component to detect when the dynamic component has rendered to the DOM.

So that’s easy-ish:

<template>
  <template lwc:if={isLoading}>
    <lightning-spinner size="medium" title="loading"></lightning-spinner>
  </template>
  <template lwc:else>
    <template for:each={dynamicComponents} for:item="dynamicComponent">
      <lwc:component
        lwc:is={dynamicComponent.componentConstructor}
        key={dynamicComponent.ref}
        lwc:spread={dynamicComponent.props}
      ></lwc:component>
    </template>
  </template>

  <lightning-button
    onclick={handleClick}
    label="Click to Log"
  ></lightning-button>
</template>

And then in the controller:

import { api, LightningElement } from "lwc";

export default class LwcCreator extends LightningElement {
  @api
  componentNames;

  dynamicComponents = [];
  isLoaded = false;

  async connectedCallback() {
    // here I prefer a falsy check rather than
    // awaiting on an empty list
    this.dynamicComponents = (
      !this.componentNames
        ? []
        : await Promise.all(
            this.componentNames.split(",").map((componentName, index) =>
              import(componentName)
                .then(({ default: lwcClass }) => ({
                  componentConstructor: lwcClass,
                  id: index,
                  ref: componentName.split("/")[1] + index,
                  // we'll come back to the props ... property ... in a bit
                  props: {},
                }))
                .catch(() => ({
                  componentConstructor: false,
                })),
            ),
          )
    ).filter(({ componentConstructor }) => !!componentConstructor);
  }

  renderedCallback() {
    if (this.dynamicComponents.length !== 0) {
      this.isLoaded = !!this.template.querySelector(
        this.dynamicComponents[this.dynamicComponents.length - 1].componentName,
      );
    }
  }

  handleClick() {
    const logger = this.template.querySelector("c-logger");
    logger.debug("Clicked from button!");
    logger.saveLog();
  }
}

That assumes that the last component is the last to be loaded — we could also do a reduce where isLoaded is only true when all of the components have finished mounting to the DOM:

<template>
  <template lwc:if={isLoading}>
    <lightning-spinner size="medium" title="loading"></lightning-spinner>
  </template>
  <template lwc:else>
    <template for:each={dynamicComponents} for:item="dynamicComponent">
      <lwc:component
        // 👇
        data-id={dynamicComponent.id}
        lwc:is={dynamicComponent.componentConstructor}
        lwc:spread={dynamicComponent.props}
        key={dynamicComponent.ref}
      ></lwc:component>
    </template>
  </template>

  <lightning-button
    onclick={handleClick}
    label="Click to Log"
  ></lightning-button>
</template>

And then in the JavaScript controller:

import { api, LightningElement } from "lwc";

export default class LwcCreator extends LightningElement {
  @api
  componentNames;

  dynamicComponents = [];
  isLoaded = false;

  async connectedCallback() {
    if (!this.componentNames) {
      this.isLoaded = true;
      return;
    }

    this.dynamicComponents = (
      await Promise.all(
        this.componentNames.split(",").map((componentName, index) =>
          import(componentName)
            .then(({ default: lwcClass }) => ({
              componentConstructor: lwcClass,
              id: index,
              ref: componentName.split("/")[1] + index,
              props: {},
            }))
            .catch(() => ({
              componentConstructor: false,
            })),
        ),
      )
    ).filter(({ componentConstructor }) => !!componentConstructor);
  }

  renderedCallback() {
    if (this.dynamicComponents.length !== 0) {
      this.isLoaded = this.dynamicComponents.reduce(
        (previous, currentComponent) =>
          previous &&
          this.template.querySelector(`[data-id="${currentComponent.id}]`),
        true,
      );
    }
  }

  handleClick() {
    const logger = this.template.querySelector("c-logger");
    logger.debug("Clicked from button!");
    logger.saveLog();
  }
}

Separation Of Concerns

As for the handleClick function — in reality, this doesn’t really belong to the lwcCreator component. This component shouldn’t know anything about the components that it’s responsible for loading up. What are some responsibilities that I can imagine for this component? Instead of using componentNames as a comma-separated list of stateless components that get passed in, what if dynamicComponents itself was the @api decorated property?

In that case, lwcCreator would have the ability to differentiate itself with some really interesting capabilities; it would be a great place, for example, to detect that something like a <lightning-flow> component was being injected, and validate the props for that component (the same could be said for validating the props of any other component passed in). And appending data properties would ensure compatibility with multiple instances of the same component, allowing for things like A/B testing the UI experience.

In general, I think this is where data-driven components have the potential to get really interesting — given a key provided via a flexipage or parent-level component, fetch a list of components and (for each of those components), a child list of properties to be used. Depending on your org setup and needs, a junction object might be used for relating components to different properties:

ERD showing possible object model

In this way, shared properties can be associated with multiple components easily, and validated by the lwcCreator component.

Packaging

At the moment, dynamic LWCs can be packaged by both 1GP and 2GP packages — with an important caveat. Lightning Web Security (LWS) is required to be enabled for any page using dynamic Lightning Web Components to render properly. I’m pushing for a better experience for orgs that don’t have LWS enabled, as that would allow ISVs and open source package creators to gracefully handle the cases where orgs don’t have LWS enabled without the page failing to render. I don’t have a timeline on the changes I’ve proposed to this experience, which is preventing me from packaging the component I’ll show off next. If you’re only ever shipping to orgs with LWS enabled, dynamic LWCs are definitely ready to be used, and I’d highly recommend employing them to decouple code between different teams.

Example Logger Component For Apex Rollup

In Apex Rollup’s LWCs, there are a few places where I’ve chosen to print to the console like such:

try {
  // some stuff that might throw
} catch (e) {
  const errorMessage =
    Boolean(e.body) && e.body.message ? e.body.message : e.message;
  this._displayErrorToast("An error occurred while rolling up", errorMessage);
  // eslint-disable-next-line
  console.error(e); // in the event you dismiss the toast but still want to see the error
}

But what I’d really like to do is use Nebula Logger’s logger when an org has Nebula installed — and that’s now possible with dynamic LWCs.

In my markup, I can add the following:

<c-rollup-logger></c-rollup-logger>

And then in c/rollupLogger (in addition to the <capabilities> node I’ve already shown in the meta file!):

import { api, LightningElement } from "lwc";

export default class RollupLogger extends LightningElement {
  @api
  logger;

  connectedCallback() {
    import("c/logger")
      .then(async ({ createLogger }) => (this.logger = await createLogger()))
      .catch((err) => {
        this.logger = {
          debug: console.debug,
          info: console.info,
          warn: console.warn,
          error: (...args) => {
            console.error(...args);
            return { setError: (err) => console.error(formatError(err)) };
          },
          saveLog: () => {
            console.warn(
              "Logs will not be persisted beyond the console as Nebula Logger is not installed in this org",
            );
          },
        };
        this.logger
          .error("Error occurred while loading Nebula Logger")
          .setError(err);
      });
  }
}

const formatError = (err) =>
  JSON.stringify(
    {
      message: err.body?.message ?? err.message,
      stack: err.stack,
    },
    null,
    2,
  );

After that, it’s as simple as changing the originally shown catch block:

const errorMessage =
  Boolean(e.body) && e.body.message ? e.body.message : e.message;
this._displayErrorToast("An error occurred while rolling up", errorMessage);
const possibleLogger = this.template.querySelector("c-rollup-logger")?.logger;
if (possibleLogger) {
  possibleLogger.error("An error occurred while rolling up").setError(e);
  possibleLogger.saveLog();
}

For orgs with Nebula, the following then prints to the console when there’s an error:

Example error using a dynamically generated Nebula Logger component

And for orgs without Nebula, the console can still be used:

Example error showing only messages printed to the console in an org without Nebula Logger

Wrapping Up

Hopefully these examples help to show off how powerful dynamic Lightning Web Components can be. I’m hopeful that the barriers preventing them from easily being packaged — due to not knowing whether subscribers will have LWS enabled or not — will be addressed in the near future, and I’ll update this article accordingly when that happens. In the meantime, particularly if you’re working in a large org with multiple development teams, dynamic LWC can help in a variety of ways:

  • safely decoupling components on Flexipages that your team doesn’t necessarily “own”
  • allows you to gracefully handle rendering errors without interrupting page load/interactions
  • promotion of low-code management for LWC additions to pages
  • possibility for easy, record-based, sharing of component properties
  • possibility of split test-able components (and, as a result, user journeys)

Looking forward to hearing how people have started to already take advantage of dynamic LWCs — definitely share what you’ve done with them! As always, thanks for reading the Joys Of Apex, and a big thanks to my sponsors on Patreon — particularly Henry Vu and Arc. It means a lot to me!

In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!