DEV Community

ezzabuzaid for This is Angular

Posted on • Updated on • Originally published at techtext.dev

Advance Angular Dynamic Component

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);
Enter fullscreen mode Exit fullscreen mode

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;
}[];
Enter fullscreen mode Exit fullscreen mode

Each of which has propName and templateName that corresponds to

@Input(templateName) propName;
@Output(templateName) propName;
Enter fullscreen mode Exit fullscreen mode

templateName defaults to propName if not specifed.

Setup

Our directive would be used like this

<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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.`);
    }
}
Enter fullscreen mode Exit fullscreen mode

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() { }
}
Enter fullscreen mode Exit fullscreen mode

To complete the setup we need to make sure that

  1. outputs/inputs object corresponds to component outputs/inputs, no incorrect names used.
  2. component ngOnChange runs on input change.
  3. 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.`);
      }
  });
}
Enter fullscreen mode Exit fullscreen mode

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`);
      }
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
              }
          });
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Create changes object with firstChange as true after creating the component.
  2. 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.
  3. Rebind the inputs only if they did change.
  4. Rebind the outputs only if they did change.
  5. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Resources

  1. Here is what you need to know about dynamic components in Angular
  2. NgComponentOutlet
  3. Dynamically Creating Components with Angular.

Top comments (7)

Collapse
 
ezzabuzaid profile image
ezzabuzaid • Edited

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..."

Collapse
 
davdev82 profile image
Dhaval Vaghani

Good Article !!!

Collapse
 
remshams profile image
Mathias Remshardt • Edited

This is/was a really interesting read.

As an alternative I could think of:

  • an Observable(Inputs) or a Subject(Outputs) passed through to the dynamic component using the injector
  • a shared service injected into the dynamic component

Your 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.

Collapse
 
rajsaha profile image
Raj Saha

This is legitimately awesome. Thank you!

Collapse
 
rafata profile image
rafat-a

This is great! thanks for sharing

Collapse
 
zohar1000 profile image
Zohar Sabari

impressive!

Collapse
 
adisreyaj profile image
Adithya Sreyaj

Interesting āš”