Dynamic LWC Creation: Part Two
Table of Contents:
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:
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:
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:
And for orgs without Nebula, the console can still be used:
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!