Dynamic LWC Creation
Lightning Web Components (LWCs) have become the de facto standard when it comes to frontend development on the Salesforce platform. They’re a massive step above the prior framework, Aura, in terms of quality, idiomaticness, and testability. Most people aren’t looking for Aura to make a comeback. That being said, there are a few things we’ve had to sacrifice, or wait for, as the LWC framework has matured — modals were an obvious example of this (I wrote about this in LWC Composable Modal), though that gap has now largely been bridged. Dynamic component creation is another area where a feature gap still exists. In LWC Utils, James Hou came up with a solution that has been widely propagated in the ensuing years — having one “parent”-level Aura component that listens for any children LWCs that need to create other components. This works fine when you control all aspects of a page, or a set of LWCs, but that’s not always the case.
Why might one need to show a LWC dynamically? There are many possible reasons (of which I’ll list a few):
- you work on a team that interacts with other teams. Your team “owns” some portion of a flexipage and you’d like to decouple your own component — giving you flexibility in what component to show — within a larger LWC owned by another team. This could also apply if you’re developing packages and you want to centralize LWC creation
- you (being very lucky) have no Aura components and no interest in paying the “Aura tax” when it comes to performance. You still want to be able to orchestrate dynamic LWC creation without having to hard code a bunch of
<template lwc:if>
conditionals - you have an existing Apex controller method that currently returns information about a specific LWC implementation, and you’d now like to either swap out that implementation or have been asked to conditionally show another LWC instead of the default implementation (and, as always, once you’ve gotten a request for one conditional, chances are there’ll be more conditions further one down the line)
- you’re an ISV or OEM, or create packages in general where subscribers need to be able to define customized components via CMDT or record-based means
Let’s dive in.
Screen Flows In LWC & LWCs In Screen Flows
In Winter ‘23, the <lightning-flow>
component was added to the base LWC component library with little fanfare. It was only recently that a colleague wrote about Embedding Screen Flows in LWC, but prior to that I was already toying with the idea due to a situation I wanted to improve at work. Since screen flows have long had the capability to inject Lightning Web Components into them, this seemed like the perfect way to get around the fact that there’s no $A.createComponent
equivalent in LWC.
I’ll detail the basic setup we had:
- for years, we’d provided an href target for another team that kicked off a user interaction
- earlier this year, we took a look at the existing implementation for that interaction — a series of guided screens — and decided they proved insufficient for our needs, given changes in requirements and a need for dynamicism that the old web page couldn’t provide
- we wanted to replace this web page with our own LWC, but because we had to continue to support a link-accessible version of our LWC, we wrapped the whole thing in an Aura component that was URL addressable
- the existing link launches said Aura component in a new tab; what we really wanted was to instead display our new and improved interaction by means of the existing LWC on the existing page
The thing that initially caught my eye when looking at the <lightning-flow>
docs was this bit, about passing variables into your flow:
<!-- markup -->
<template>
<lightning-flow
flow-api-name="some_flow_api_name"
flow-input-variables={inputVariables}
onstatuschange={handleStatusChange}
>
</lightning-flow>
</template>
// js controller
get inputVariables() {
return [
{
name: 'OpportunityID',
type: 'String',
value: '<Opportunity.Id>'
},
{
name: 'AccountID',
type: 'String',
value: '<Opportunity.AccountId>'
}
];
}
handleStatusChange(event) {
// ...
}
Since, in our own use-case, there was already an Apex controller call being made, this seemed like the perfect time to create an object that represented those input variables:
public class FlowBundle {
public interface ComponentCreator {
List<Component> getComponents();
}
public class Component {
public Component(String screenFlowApiName) {
this.flowApiName = screenFlowApiName;
}
@AuraEnabled
public String flowApiName { get; private set; }
@AuraEnabled
public final List<InputVariable> flowInputVariables = new List<InputVariable>();
}
public class InputVariable {
public InputVariable(String name, Type type, Object value) {
this.name = name;
this.type = type.getName();
this.value = value;
}
@AuraEnabled
public final String name;
@AuraEnabled
public final String type;
@AuraEnabled
public final Object value;
}
}
While I can see some benefits to leaving this sort of information to be only handled on the frontend, the nice thing about having it in Apex is that the type signatures for the FlowBundle.InputVariable
instances can be strongly typed; additionally, the FlowBundle.ComponentCreator
interface can be updated or extended to have arguments passed to it if needed.
If we imagine the scenario I was outlining earlier — where a controller call is made with a record Id that could be of many different types — it’s now possible to coordinate with the other team(s) on cutting over to this updated behavior without making any breaking changes on either end:
@AuraEnabled
public List<FlowBundle.Component> getLightningWebComponentsDynamically(Id recordId) {
List<FlowBundle.Component> components = new List<FlowBundle.Component>();
// first we want to define the implementation that can be used once everything is in place
// note that the string here should come from CMDT or any other dynamically retrieved place
// rather than also being hardcoded
Object potentialImplementation = Type.forName('somePredefinedClass')?.newInstance();
if (potentialImplementation instanceof FlowBundle.ComponentCreator) {
components.addAll(((FlowBundle.ComponentCreator) potentialImplementation).getComponents());
} else {
// define a fallback that can later be removed once all parties have implemented
// their part in the initial if block
components.add(new FlowBundle.Component('fallbackScreenFlowName'));
}
// now agree on a set of common properties that SHOULD be passed to all screen flows
for (FlowBundle.Component comp : components) {
comp.flowInputVariables.add(
new FlowBundle.InputVariable(
'recordId',
String.class,
recordId
)
);
}
}
Back on the LWC side, regardless of whether this controller call is wired or called imperatively (but I’ll show the imperative version):
// this property needs to be declared so that the LWC can respond to it being assigned
flowComponents;
// and then in some method
this.flowComponents = await getLightningWebComponentsDynamically({
recordId: this.recordId,
});
And then in the markup (simple example — let’s assume there would be other markup, as well):
<template>
<c-screen-flow-creator flow-components={flowComponents}>
</template>
Leaving us, at last, with the injection of the Screen Flow(s) themselves:
// in c-screen-flow-creator
<template>
<template lwc:if={flowComponents}>
<template for:each={flowComponents} for:item="flowComponent">
<lightning-flow
flow-api-name={flowComponent.flowApiName}
flow-input-variables={flowComponent.flowInputVariables}
key={flowComponent.flowApiName}
onstatuschange={handleStatusChange}
></lightning-flow>
</template>
</template>
</template>
And in its JavaScript controller:
import { api, LightningElement } from "lwc";
export default class ScreenFlowCreator extends LightningElement {
@api
flowComponents;
handleStatusChange(event) {
// to remove all rendered elements from the page
if (event.detail.status === "FINISHED") {
this.flowComponents = null;
}
}
}
So where does that leave us? At this point, we have “dynamic” LWCs — but they have to expose Screen Flow as a target. This creates a one-to-one binding between the LWCs you’re creating and the Screen Flow that acts as its wrapper. It also means that any LWC that we plan to expose dynamically needs to fire the FlowNavigationFinishEvent
(unless there’s no chance of the component being reinitialized, as in the case of something that’s shown once dynamically and then is “always there” on the page. A modal would need to fire the finish event, though, when it was closed). You can also get away with not firing the event if you’re creating the screen flow within your LWC from a button press or something to that effect where the inner ScreenFlowCreator
LWC is shown/hidden through conditional rendering.
What that ends up looking like could take many different flavors. I personally am a big fan of the CMDT-driven approach when providing something like a screen flow name dynamically, but even if we were to assume the worst possible architecture, you can still end up with a front-end presentation element that’s nicely decoupled from what LWC is shown:
<template>
<div class="slds-theme_default slds-var-p-around_large">
Example LWC that dynamically gets Screen Flows with other LWCs in them
<div class="slds-var-m-top_large">
<lightning-button
variant="brand"
label="Click me to show!"
title="Click me to show!"
onclick={handleClick}
></lightning-button>
</div>
<template if:true={showExperiences}>
<c-screen-flow-creator
flow-components={experiences}
></c-screen-flow-creator>
</template>
</div>
</template>
And in the JS controller:
import { api, LightningElement } from "lwc";
import getExperiences from "@salesforce/apex/DynamicFlowExperienceController.getExperiences";
export default class ExampleLwc extends LightningElement {
experiences;
showExperiences = false;
async connectedCallback() {
// this could be made dynamic on the JS side, as well as on the Apex side.
const experienceName = "firstExperience";
this.experiences = await getExperiences({ experienceName });
}
handleClick() {
this.showExperiences = !this.showExperiences;
}
}
Lastly, on the Apex side:
public class DynamicFlowExperienceController {
@AuraEnabled
public static List<FlowBundle.Component> getExperiences(String experienceName) {
// again, the best possible situation would be retrieving this/these
// via CMDT based on parameters supplied here
List<FlowBundle.Component> components = new List<FlowBundle.Component>();
switch on experienceName {
when 'firstExperience' {
FlowBundle.ComponentCreator experienceOneCreater = new ExampleImplementer();
components.addAll(experienceOneCreator.getComponents());
}
when else {
FlowBundle.Component fallbackBundle = new FlowBundle.Component();
fallbackBundle.flowApiName = 'fallbackScreenFlowApiName';
// you can even continue to pass down any variables passed in
// for further dynamic behavior at the screen flow / injected LWC
// level
fallbackBundle.flowInputVariables.add(
new FlowBundle.InputVariable(
'experienceName',
String.class,
experienceName
)
);
components.add(fallbackBundle);
}
}
return components;
}
private class ExampleImplementer implements FlowBundle.ComponentCreator {
public List<FlowBundle.Component> getComponents() {
List<FlowBundle.Component> components = new List<FlowBundle.Component>();
// again, in actuality this would be fed by CMDT
components.add(new FlowBundle.Component('Dynamic_LWC_SOQL_datatable'));
return components;
}
}
}
This ends up looking a bit like this:
And when clicked (using a Screen Flow that automatically embeds SOQL Datatable):
If I update my ExampleImplementer
class to use another flow name, my view upon clicking is immediately updated to a version of the composable LWC modal:
I can’t stress enough how powerful this mechanism is, especially when fed by Custom Metadata Type records. Proper dynamism in Lightning Web Components has long been lacking; now we’re back to one of the areas of the platform that I think is Salesforce’s bread and butter — metadata-driven decisions (which, in this case, help to inform what sort of UI we need to show). This means we don’t have to update the ExampleLwc
when we want to show something different — that behavior is now controllable by admins and developers alike (and while here we’re talking about dynamically showing LWCs within screen flows, simply having access to dynamically instantiated screen flows is already bridging the gap between Flow builders and developers).
Notes
Each screen flow either needs to have input variables declared that will then get passed on to the encapsulated LWC, or (if your LWC has @api
decorated properties that the flow will already “know” upfront, like whether or not a modal needs to be opened by default) the screen flow can fetch or set the LWCs variables based on a combination of input variables and logic in the flow itself. You cannot pass an input variable to a flow from LWC’s <lightning-flow>
without that attribute being defined for input on the Flow itself — and you’ll get an error message if you try to do this.
Paying The Aura Tax
One might be tempted to call it quits at this point — certainly I would be for the original example I was talking about. The performance of embedding a LWC into a screen flow which is in itself embedded in a parent LWC is pretty good. We could just move on to the Wrapping Up section and move on with our lives. But this is the Joys Of Apex! We have to keep going, for science, even if I personally wouldn’t use this approach in production — even if I could get it working, which so far hasn’t proven to be the case.
To start, though, let’s review the requirements for Apex-defined types in Flow:
- Supported data types in an Apex class are Boolean, Integer, Long, Decimal, Double, Date, DateTime, and String. Single values and lists are supported for each data type. Multiple Apex classes can be combined to represent complex web objects.
- The
@AuraEnabled
annotation for each field is required. - A constructor with no arguments is required.
- Class methods aren’t supported.
- Getter methods for fields aren’t supported.
- Inner classes aren’t supported.
- An outer class that has the same name as an inner class isn’t supported.
- Referential integrity isn’t supported for Apex class fields. For example, a flow has an Apex-defined variable that represents the model field in the Car Apex class. If the model field is modified or deleted in the class, the flow fails.
So — there are a lot of caveats to using this approach, and one of the big ones that jumps out immediately is the lack of support for generic Object
types.
Additionally, there’s a piece of guidance when it comes to exposing Aura design properties to flow that we need to keep in mind for Apex defined types (basically the same as above):
- Supported data types in an Apex class are Boolean, Integer, Long, Decimal, Double, Date, DateTime, and String. Single values as well as Lists are supported for each data type.
On the surface, this all seems very promising, because it has the potential to break the 1:1 mapping outlined in the previous section between a Screen Flow and each LWC we’d like to create. If we can embed an Aura component into a singular screen flow and successfully pass variables from the outer LWC to the Screen Flow, and finally to the Aura component … then we can make use of $A.createComponent
to create our Lightning Web Components dynamically.
So let’s get started by revisiting our FlowBundle
class:
public virtual class FlowBundle {
public interface ComponentCreator {
List<Component> getComponents();
}
public class Component {
public Component(String screenFlowApiName) {
this.flowApiName = screenFlowApiName;
FlowInputVariable inputWrapper = new FlowInputVariable();
inputWrapper.name = 'inputVariables';
// this is ALWAYS Apex
inputWrapper.type = 'Apex';
this.inputWrappers.add(inputWrapper);
}
@AuraEnabled
public String lwcName;
@AuraEnabled
public final String flowApiName { get; private set; }
@AuraEnabled
public final List<FlowInputVariable> inputWrappers = new List<FlowInputVariable>();
}
public class FlowInputVariable {
@AuraEnabled
public String name;
@AuraEnabled
public String type;
// technically this can be either a list OR a single value
// but since we don't support union types, best to simply
// append to the list with a singular value when that's what
// needs to be passed
@AuraEnabled
public final List<FlowBundle> value = new List<FlowBundle>();
}
@AuraEnabled
public String name;
@AuraEnabled
public String type;
@AuraEnabled
public String value;
}
Note the existence of those outer @AuraEnabled
properties now, and the other modifications. This allow us, in Apex-land, to continue strongly typed usage of our Flow variables while still conforming to the requirements of the bullet points above. In the end, we would probably have the flowApiName
property be set here, as well, if there’s only going to be one Screen Flow for every instance of FlowBundle.Component
. We also could get a cleaner structure going instead of the properties being duplicated between the outer/inner classes. For now, I’m glossing over this because there are other major stumbling blocks afoot, which you’ll see shortly.
So, we create an Aura component with the same variables and expose it to Screen flows:
<aura:component implements="lightning:availableForFlowScreens">
<aura:handler name="init" value="{!this}" action="{!c.init}" />
<aura:attribute name="lwcName" type="String" />
<aura:attribute name="inputVariables" type="FlowBundle[]" />
{!v.body}
</aura:component>
And then in its JS controller:
({
init: function (component, event) {
$A.createComponent(
component.get("v.lwcName"),
component.get("v.inputVariables"),
(dynamicComponent, status, _) => {
if (status === "SUCCESS") {
const body = cmp.get("v.body");
body.push(dynamicComponent);
cmp.set("v.body", body);
}
},
);
},
});
Finally, the Screen Flow needs to have those same input variables defined; at this point, it should be as simple as embedding the Aura component in the Screen Flow, passing the input variables to the Aura component, and calling it a day.
Let’s update the <c-screen-flow-creator>
to make use of the updated shape of the FlowBundle
classes:
<template>
<template if:true={flowComponents}>
<template for:each={flowComponents} for:item="flowComponent">
<lightning-flow
flow-api-name={flowComponent.flowApiName}
flow-input-variables={flowComponent.inputWrappers}
key={flowComponent.flowApiName}
onstatuschange={handleStatusChange}
></lightning-flow>
</template>
</template>
</template>
import { api, LightningElement } from "lwc";
export default class ScreenFlowCreator extends LightningElement {
@api
flowComponents;
_flowComponents;
renderedCallback() {
// we have to clone the passed-in input(s)
// prior to them being passed to Flow
// that's just the way it is!
if (!this._flowComponents && this.flowComponents) {
this._flowComponents = JSON.parse(JSON.stringify(this.flowComponents));
}
}
// ... etc
}
The Aura component is successfully created and the init
method on it is called. At this point, that means we’re able to make use of a single screen flow to curry data to Aura, allowing us to create as many LWCs as we want while only having a single Screen Flow to delegate to. However, we have to keep in mind a few caveats:
- Since “Boolean, Integer, Long, Decimal, Double, Date, DateTime, and String” are the only supported data types, and I’ve only shown off the passing of text-based input variables here, it would fall to the Aura component to have a parsing strategy to reconstitute the different types for each
value
for theFlowInputVariable
classes - this is exacerbated by not being able to use the base
Object
type forvalue
:
public class FlowBundle {
// ...
@AuraEnabled
public String name;
@AuraEnabled
public String type;
@AuraEnabled
public Object value;
}
We could subclass FlowBundle
and supply all the different allowed types for value
:
public virtual class FlowBundle {
// ...
@AuraEnabled
public String name;
@AuraEnabled
public String type;
public class StringInput extends FlowBundle {
@AuraEnabled
public String value;
}
public class NumberInput extends FlowBundle {
@AuraEnabled
public Decimal value;
}
public class BooleanInput extends FlowBundle {
@AuraEnabled
public Boolean value;
}
// etc ...
}
This would probably scale better than any kind of string-parsing strategy, but … it doesn’t work. As soon as we subclass FlowBundle
, and remove the base value
property, the input variables fail to be passed to Aura successfully. This isn’t just because what I’m showing here is using inner classes — even outer classes extending FlowBundle
fail to have their value
property passed successfully. As well, no complex objects, even those made up of only the aforementioned allowed property types, are supported, which means any LWC you create in this fashion can only accept primitives as @api
properties.
Oh well! Thus concludes our fun experiment on the Aura side. The performance tax is definitely there — initializing the Aura component alone is noticeably slower — but perhaps for some the dynamic upside will be worth that cost.
Wrapping Up
When I wrote Two Years In Open Source, I thought I was done writing for the year. I don’t post with any kind of frequency for a reason; I’m not trying to hold to a cadence, but rather to write about things on-platform that I find interesting in the hopes that they provide good food for thought. The <lightning-flow>
LWC is definitely a great example of a newer addition to the platform that I think has a ton of potential, and it got me excited to write another article before the year was up. Despite running into a few walls while experimenting with it on the Aura side of things, I was very pleased with the performance of the LWC -> Screen Flow -> LWC use-case. I think the LWC -> Screen Flow -> Aura -> LWC route is also fun and potentially allows for more reusability, at the expense of some additional boilerplate and sacrifice.
Since writing this post initially, the <lighting-component>
dynamic markup functionality has gone GA, which means that this sort of workaround isn’t really necessary anymore — for more on that, make sure to read Dynamic LWC Creation Part 2!
I hope you enjoyed seeing how useful the new <lightning-flow>
component is for LWC, and the possibilities that being able to use Aura/LWC within Screen Flow open up. With great flexibility comes great power, and I’m excited to see what dynamic solutions people dream up making use of these components in tandem. Till next post, and next year, you have my gratitude if you’ve made it this far.
Thanks, as always, to Henry Vu for his support on Patreon.