Why Direct Binding Falls Short
When creating dynamic components, the two primary mechanisms — ngComponentOutlet and ComponentFactoryResolver — do not offer native support for input and output binding. This is a significant limitation for developers.
Furthermore, ngOnChanges does not function in dynamically created components. As explained in detail here, the compiler generates the function responsible for input checks during the build process, and it is not available at runtime for these dynamic instances.
A Directive-Based Approach
To bridge this gap, we can create a custom directive that simplifies the binding process. By leveraging ComponentFactoryResolver, we can obtain a factory object that contains valuable metadata about the dynamic component's inputs and outputs. This metadata is crucial for ensuring that we use the correct property names when setting up bindings.
const factory = componentFactoryResolver.resolveComponentFactory(ComponentType);
The factory object exposes two getter properties that list the component's 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 entry in these lists contains two key fields: propName and templateName.
@Input(templateName) propName;
@Output(templateName) propName;
If no alias is provided, templateName defaults to the value of propName.
Setting Up the Directive
Consider the intended usage pattern for our directive:
<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>
Defining the necessary types
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';
A helper function for type safety 😅
function assertNotNullOrUndefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`cannot be undefined or null.`);
}
}
The core directive implementation
@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() { }
}
For this setup to be complete, we must handle three critical aspects:
- Ensure that the keys in the user-provided
inputsandoutputsobjects perfectly match the component's actual property names. - Make sure the component's
ngOnChangeslifecycle hook is triggered whenever an input value changes. - Automatically clean up subscriptions to output
EventEmitterinstances.
I'll demonstrate the key functions to highlight the core logic. For a full view, you can browse the complete source code.
Ensuring Name Validity
Since this solution is custom-built and doesn't come with Angular's built-in template type checking, we must manually validate the user-provided names against the component's metadata. This prevents subtle runtime errors and typos.
As previously mentioned, the ComponentFactory object is key to this inspection.
Validating Inputs
We iterate over the user-provided inputs. For each, we confirm it is a legitimately declared @Input field of the target component.
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.`);
}
});
}
Validating Outputs
First, we ensure that all component outputs are, in fact, instances of EventEmitter. An output is defined as a property decorated with @Output.
Next, we check the user-provided outputs. For each, we confirm two things: it is a declared output of the component, and the corresponding value in the user's object is a function. This function will act as the handler, or subscriber, to the EventEmitter.
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`);
}
});
}
Handling Data Flow
Now that we're working with correctly validated input and output names, the binding process becomes quite straightforward.
Setting Inputs
private bindInputs(componentInputs: ComponentInputs, userInputs: UserInputs, componentInstance: any) {
componentInputs.forEach((input) => {
const inputValue = userInputs[input.templateName];
componentInstance[input.propName] = inputValue;
});
}
Handling Outputs
The takeUntil operator is employed to later manage the unsubscription from a given EventEmitter.
The property this.subscription represents a Subject instance, which will be initialized in the upcoming 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);
}
});
});
}
Instantiating the Component
The process of generating dynamic components relies on two key services: ComponentFactoryResolver and ViewContainerRef.
To start, we obtain a factory through ComponentFactoryResolver. This factory object carries the essential metadata required for validating the component's inputs and outputs.
Next, this factory is used in conjunction with ViewContainerRef to instantiate the component. This step also accepts the injector, which will be defined later in the process.
private createComponent() {
this.componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.component);
this.componentRef = this.viewContainerRef.createComponent<any>(this.componentFactory, 0, this.injector);
}
Resource Cleanup
To properly dispose of a component, the destroy method from ComponentRef is called initially. Afterward, we clear the ViewContainerRef, which holds the actual component reference; this action will also make the component disappear from the view.
private destroyComponent() {
this.componentRef?.destroy();
this.viewContainerRef.clear();
}
This cleanup logic is executed within the ngOnDestroy lifecycle hook. The subscription, as noted earlier, is a Subject instance that served the purpose of unsubscribing from the EventEmitter subscriptions.
ngOnDestroy(): void {
this.destroyComponent();
this.subscription.next();
this.subscription.complete();
}
Orchestrating the Workflow
Let's bring all these pieces together. The ngOnChanges lifecycle hook will be the trigger for component creation whenever the component or injector input properties are modified. In such an event, the existing component is destroyed before the new one is created.
Following the instantiation, the next steps are to execute the validation, and then proceed with binding the component's 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 this setup finalized, we now possess all the necessary capabilities to accomplish tasks that are beyond the scope of what [ngComponentOutlet] can handle.
Leveraging ngOnChanges
Up to this point, we can fully create dynamic components. However, we cannot utilize the component's ngOnChanges lifecycle hook because our directive doesn't automatically react to @Input changes; this behavior must be implemented manually.
An alternative method involves converting the @Input field of interest into getter and setter functions to detect changes. This approach is generally less favorable, so we will continue with the manual implementation of ngOnChanges.
We'll begin by constructing a changes object for the component.
The fundamental logic involves iterating through the new inputs (currentInputs) and comparing each against its previous value. If a difference is detected, that input is logged as a changed input in 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>);
}
Next, we must invoke the ngOnChanges method on the component instance manually, provided that the component has implemented it and accepts the changes object as a parameter.
Let's enhance the directive's ngOnChanges hook to incorporate this 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);
}
}
- Construct a changes object with
firstChangeset to true after the component is created. - If the component itself hasn't changed, it implies only inputs or outputs have changed; in this scenario, create a changes object with
firstChangeset to false. - Rebind inputs only when they have been altered.
- Rebind outputs only when they have been altered.
- Invoke the component's
ngOnChangeslifecycle hook, providing it with the potential input changes.
Practical Demonstration
It's time to see the implementation in action. Demo
Consider this straightforward example: a component that renders a color derived from an input property and emits an event upon change.
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);
}
}
The host component declares an <ng-template> element, designating ColorBoxComponent as the dynamic-component, complete with the necessary inputs and outputs.
Pressing the Change Color button will trigger the ngOnChanges method of ColorBoxComponent, mimicking standard Angular behavior.
To see the validation logic in action, attempt to alter an input name; you should observe an exception being logged to the console.
One important note regarding outputs: it is necessary to use arrow function syntax to ensure that this correctly references 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';
}
}
Wrapping Up
Handling dynamic components is a near-universal need in Angular applications, and having straightforward methods to manage them significantly eases development.
For teams looking for an out-of-the-box solution, the ng-dynamic-component package builds upon these concepts and offers extended functionality.
