In this article, I will show you how you can still use inputs and outputs and support OnChanges lifecycle while creating dynamic components.
If you don't know about dynamic components yet, I recommend this article Dynamically Creating Components with Angular before going forward.
For clarity about what Iām going to talk about, the project is available to browse through Github. or a Demo if you prefer.
UPDATE - 5/8/2023
The component inspection API has been deprecated as of Angular V13, If you're using Angular v14 and above update your implementation to follow "https://github.com/ezzabuzaid/dynamic-component-article/blob/main/src/app/dynamic-component.directive.v14.ts"
The Problem
In order to create a dynamic component, you have to use either ngComponentOutlet
directive or ComponentFactoryResolver
object, neither provides a way to bind inputs and outputs.
moreover, ngOnChanges
won't work, This is because the function that performs inputs checks is generated by the compiler during compilation.
The Solution
To work around the problem we would use a custom directive that could help as little as possible to facilitate the bindings.
We will use ComponentFactoryResolver
to create a component factory that holds metadata about the component inputs and outputs. this metadata will be used to ensure correct properties names of inputs and outputs are used.
const factory = componentFactoryResolver.resolveComponentFactory(ComponentType);
factory
has two getters that represent the component inputs and outputs.
/**
* The inputs of the component.
*/
abstract get inputs(): {
propName: string;
templateName: string;
}[];
/**
* The outputs of the component.
*/
abstract get outputs(): {
propName: string;
templateName: string;
}[];
Each of which has propName
and templateName
that corresponds to
@Input(templateName) propName;
@Output(templateName) propName;
templateName
defaults to propName
if not specifed.
Setup
Our directive would be used like this
<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>
Types that will be used in the code
type UserOutputs = Record<string, (event: any) => void>;
type UserInputs = Record<string, any>;
type ComponentInputs = ComponentFactory<any>['inputs'];
type ComponentOutputs = ComponentFactory<any>['outputs'];
type Color = 'red' | 'blue' | 'green';
Utility function for strict mode people š
function assertNotNullOrUndefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`cannot be undefined or null.`);
}
}
The directive
@Directive({
selector: '[dynamic-component]',
})
export class DynamicComponentDirective implements OnDestroy, OnChanges {
@Input('dynamic-component') component!: Type<any>;
@Input() outputs?: UserOutputs = {};
@Input() inputs?: UserInputs = {};
ngOnChanges(changes: SimpleChanges) { }
ngOnDestroy() { }
}
To complete the setup we need to make sure that
-
outputs
/inputs
object corresponds to component outputs/inputs, no incorrect names used. -
component
ngOnChange
runs on input change. - outputs
EventEmitter
are auto unsubscribed from.
I'll show a few functions implementation to better highlight how things are done. You might want to check the complete code while reading the following section.
Validation
Since this is not Angular out-of-the-box solution we can't ensure the correct inputs/outputs names are used hence manual validation is required to avoid hidden issues.
As mentioned above ComponentFactory
object will be used to inspect component inputs and outputs,
Inputs
Loop over the user-provided inputs, check if each provided input is declared in the component as Input.
A component input is a field decorated with @Input
.
private validateInputs(componentInputs: ComponentInputs, userInputs: UserInputs) {
const userInputsKeys = Object.keys(userInputs);
userInputsKeys.forEach(userInputKey => {
const componentHaveThatInput = componentInputs.some(componentInput => componentInput.templateName === userInputKey);
if (!componentHaveThatInput) {
throw new Error(`Input ${ userInputKey } is not ${ this.component.name } input.`);
}
});
}
Outputs
Loop over the component outputs, check if each output holds an instance of EventEmitter
.
A component output is a field decorated with @Output
and has EventEmitter
instance as value.
In the other part, we perform a loop over the user-provided outputs, check if each provided output is declared in the component as Output and if the user-provided output is function. that function will be used as EventEmitter
handler.
private validateOutputs(componentOutputs: ComponentOutputs, userOutputs: UserOutputs, componentInstance: any) {
componentOutputs.forEach((output) => {
if (!(componentInstance[output.propName] instanceof EventEmitter)) {
throw new Error(`Output ${ output.propName } must be a typeof EventEmitter`);
}
});
const outputsKeys = Object.keys(userOutputs);
outputsKeys.forEach(key => {
const componentHaveThatOutput = componentOutputs.some(output => output.templateName === key);
if (!componentHaveThatOutput) {
throw new Error(`Output ${ key } is not ${ this.component.name } output.`);
}
if (!(userOutputs[key] instanceof Function)) {
throw new Error(`Output ${ key } must be a function`);
}
});
}
Binding
Binding is pretty straightforward now since we won't have incorrect inputs/outputs names.
Inputs
private bindInputs(componentInputs: ComponentInputs, userInputs: UserInputs, componentInstance: any) {
componentInputs.forEach((input) => {
const inputValue = userInputs[input.templateName];
componentInstance[input.propName] = inputValue;
});
}
Outputs
takeUntil
operator used to unsubscribe from the EventEmitter
instance later on.
this.subscription
is an instance of Subject
, which will be declared in the next sections.
private bindOutputs(componentOutputs: ComponentInputs, userOutputs: UserInputs, componentInstance: any) {
componentOutputs.forEach((output) => {
(componentInstance[output.propName] as EventEmitter<any>)
.pipe(takeUntil(this.subscription))
.subscribe((event) => {
const handler = userOutputs[output.templateName];
if (handler) { // in case the output has not been provided at all
handler(event);
}
});
});
}
Creating The Component
Creating dynamic components is done using ComponentFactoryResolver
and ViewContainerRef
.
First, we create a factory using ComponentFactoryResolver
, the factory contains the metadata to perform inputs/outputs validation.
Second, we use that factory to create the component using ViewContainerRef
, it also takes the injector, which will be declared later on.
private createComponent() {
this.componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.component);
this.componentRef = this.viewContainerRef.createComponent<any>(this.componentFactory, 0, this.injector);
}
Cleaning up
To destroy a component we invoke the destroy
method defined in ComponentRef
, then we clear ViewContainerRef
which holds the actual component, doing so will also remove it from the UI.
private destroyComponent() {
this.componentRef?.destroy();
this.viewContainerRef.clear();
}
the cleanup will be performed in ngOnDestroy
lifecycle, the subscription
is as mentioned previously an instance of Subject
that we used to unsubscribe from EventEmitter
subscriptions.
ngOnDestroy(): void {
this.destroyComponent();
this.subscription.next();
this.subscription.complete();
}
Combine The Functions
Let's call the functions, ngOnChanges
lifecycle will be used to create the component whenever the component
input or injector
input changes, in that case, we destroy the previous component first, then we create the new component.
after that, we perform the validation then bind the inputs and outputs.
private subscription = new Subject();
@Input('dynamic-component') component!: Type<any>;
@Input() outputs?: UserOutputs = {};
@Input() inputs?: UserInputs = {};
@Input() injector?: Injector;
ngOnChanges(changes: SimpleChanges): void {
// ensure component is defined
assertNotNullOrUndefined(this.component);
const shouldCreateNewComponent =
changes.component?.previousValue !== changes.component?.currentValue
||
changes.injector?.previousValue !== changes.injector?.currentValue;
if (shouldCreateNewComponent) {
this.destroyComponent();
this.createComponent();
}
// to make eslint happy ^^
assertNotNullOrUndefined(this.componentFactory);
assertNotNullOrUndefined(this.componentRef);
this.subscription.next(); // to remove old subscription
this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});
this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
}
with that, in place, we have all the required functionality to do what [ngComponentOutlet] can't.
The ngOnChanges
So far we can completely create dynamic components, but we can't use ngOnChanges
lifecycle since it doesn't react to @Input
changes therefore we have to do this manually.
Another way to do this is to change the @Input
field that concerned you to have getter and setter, so you can know when a change happens, but it is not a favorable option so let's stick with ngOnChanges
.
Let's start with creating changes object for the component.
Basically, do a loop over new inputs (currentInputs
) and compare each input with the previous one, in case of change we add it as changed input to the changes object
private makeComponentChanges(inputsChange: SimpleChange, firstChange: boolean): Record<string, SimpleChange> {
const previuosInputs = inputsChange?.previousValue ?? {};
const currentInputs = inputsChange?.currentValue ?? {};
return Object.keys(currentInputs).reduce((changes, inputName) => {
const currentInputValue = currentInputs[inputName];
const previuosInputValue = previuosInputs[inputName];
if (currentInputValue !== previuosInputValue) {
changes[inputName] = new SimpleChange(firstChange ? undefined : previuosInputValue, currentInputValue, firstChange);
}
return changes;
}, {} as Record<string, SimpleChange>);
}
Now, we have to manually call the ngOnChanges
from the component instance if the component declared it and passes changes as an argument.
Let's modify directive ngOnChanges
to have the functionality
ngOnChanges(changes: SimpleChanges): void {
// ensure component is defined
assertNotNullOrUndefined(this.component);
let componentChanges: Record<string, SimpleChange>;
const shouldCreateNewComponent =
changes.component?.previousValue !== changes.component?.currentValue
||
changes.injector?.previousValue !== changes.injector?.currentValue;
if (shouldCreateNewComponent) {
this.destroyComponent();
this.createComponent();
// (1)
componentChanges = this.makeComponentChanges(changes.inputs, true);
}
// (2)
componentChanges ??= this.makeComponentChanges(changes.inputs, false);
assertNotNullOrUndefined(this.componentFactory);
assertNotNullOrUndefined(this.componentRef);
this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});
// (3)
if (changes.inputs) {
this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
}
// (4)
if (changes.outputs) {
this.subscription.next(); // to remove old subscription
this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
}
// (5)
if ((this.componentRef.instance as OnChanges).ngOnChanges) {
this.componentRef.instance.ngOnChanges(componentChanges);
}
}
- Create changes object with
firstChange
as true after creating the component. - In case the component didn't change that means only the inputs or outputs did change so we create changes object with
firstChange
as false. - Rebind the inputs only if they did change.
- Rebind the outputs only if they did change.
- Calling component
ngOnChanges
lifecycle with the possible inputs changes.
Example
Time to try it out. Demo
Here's a simple component that displays a color based on input and emits an event when it changes.
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-color-box',
template: `<div style="height: 250px; width: 250px;" [style.background-color]="backgroundColor"></div>`,
})
export class ColorBoxComponent implements OnChanges {
@Input() backgroundColor: Color = 'red';
@Output() backgroundColorChanges = new EventEmitter<Color>();
ngOnChanges(changes: SimpleChanges): void {
this.backgroundColorChanges.next(changes.backgroundColor);
}
}
Host component declares <ng-template>
with ColorBoxComponent
as the dynamic-component
with inputs and outputs.
Clicking on Change Color button will invoke ngOnChanges
of ColorBoxComponent
, just as it should be.
Try to change the input name and you'll see an exception thrown in the console.
A bit about outputs, you'll need to use an arrow function syntax to have this
referring to the AppComponent
instance.
import { Component } from '@angular/core';
import { ColorBoxComponent } from './color-box.component';
@Component({
selector: 'app-root',
template: `
<ng-template
[dynamic-component]="component"
[inputs]="{backgroundColor: backgroundColor}"
[outputs]="{backgroundColorChanges: onColorChange}">
</ng-template>
<button (click)="changeColor()">Change Color</button>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
component = ColorBoxComponent;
backgroundColor: Color = 'green';
onColorChange = (value: Color) => {
console.log(value, this.backgroundColor);
}
changeColor() {
this.backgroundColor = 'blue';
}
}
Conclusion
Dynamic component is almost required in every project and having the ability to deal with it in an easy way is important.
Lastly, there's already a package that does all of that and a bit more ng-dynamic-component.
Top comments (7)
The component inspection API has been deprecated as of Angular V13, If you're using Angular v14 and above update your implementation to follow "github.com/ezzabuzaid/dynamic-comp..."
Good Article !!!
This is/was a really interesting read.
As an alternative I could think of:
Observable
(Inputs
) or aSubject
(Outputs
) passed through to the dynamic component using the injectorservice
injected into the dynamic componentYour solution has the advantage that it can almost be used as a standard
component
at the cost of writing the outlined helper implementations.Thank you for sharing the idea/solution.
This is legitimately awesome. Thank you!
This is great! thanks for sharing
impressive!
Interesting ā”