In Angular 13, the ComponentFactoryResolver became deprecated. Some libraries that depended on it had to be rewritten using the new introduced function createComponent, that is more aligned with the standalone mantra. Let's dig into it and put it to good use.
An example use of a dynamically, programmatically created component in Angular is the infamous Dialog. Today, let's go over the documentation of the createComponent
and setup the bare minimum to make use of it later.
Follow me on StackBlitz
The bare minimum
Let's first create the component that needs to be inserted programmatically, that has a simple yes, and no buttons. The following is the plan:
- Create the component programmatically from another part of the app. Let's call her Rose.
- Insert into a known HTML tag
- Insert into the HTML body
- Pass data to Rose
- Interact with Rose
Once we cover these, we can aspire for more: we will insert a new component into Rose, let's call it Peach.
Rose
Here is rose:
// src/components/rose.partial
@Component({
template: `
<button (click)="ok()">Yes</button>
<button (click)="no()">Cancel</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RosePartialComponent {
constructor() {
//
console.log('created');
}
ok(): void {
console.log('ok');
}
no(): void {
console.log('no');
}
}
In our app component, the following could be anywhere, but for simplicity let's do that in the app root component.
// main.ts, or app.component
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule],
template: `
<button (click)="insertElement()" class="btn">Insert element</button>
<!-- hosting Rosy -->
<div id="hostmehere"></div>
`,
})
export class App {
insertElement(): void {
// in here we shall create Rose and add it to hostmehere element
}
}
// bootstrap app
bootstrapApplication(App);
According to the documentation and the example provided, we need to:
- Bootstrap an application (we already did that). To get the application reference though we can simply inject it in app component constructor.
- Locate a DOM node that would be used as a host. We used
hostmehere
- Get an
EnvironmentInjector
instance from theApplicationRef
injected token - We can now create a
ComponentRef
instance. And pass to it the host element and environment injector. - Last step is to register the newly created ref using the
ApplicationRef
instance, to include the component view into change detection cycles. Let's put this off for a minute.
Let's adapt and implement all the above steps
// main.ts
// inject reference to appRef
constructor(private appRef: ApplicationRef) {}
insertElement(): void {
// in here we shall create Rose and add it to hostmehere element
const host = <Element>document.getElementById('hostmehere');
const componentRef = createComponent(RosePartialComponent, {
hostElement: host,
// the environment injector instroduced for standalone
environmentInjector: this.appRef.injector,
});
}
This should add Rosy to the hostmehere
element, and it receives clicks as expected.
Append programmatically
What if we want to remove the host element, and append to a programmatically created HTML element instead? Two ways
The simple way
The obvious way is to create a new HTML element and append it to document.body
// create a new element the simple way
// don't forget to use the right platform for SSR
const newHost = document.createElement('somenewelement');
document.body.append(newHost);
const componentRef = createComponent(RosePartialComponent, {
hostElement: newHost,
environmentInjector: this.appRef.injector,
});
The posh way
We can append the resulting component reference into the body as well. Console logging the componentRef
we have access to hostview
which contains rootNodes
array, which apparently has our HTML element. So let's append that:
// main.ts
const componentRef = createComponent(RosePartialComponent, {
environmentInjector: this.appRef.injector,
});
// append the rootNodes root after creation. That works too.
// don't forget to use the right platform for SSR
document.body.append((<any>componentRef.hostView).rootNodes[0]);
Note, I am casting everything to any
because that's beyond the point. But if you insist, the right type is EmbeddedViewRef
.
document.body.append((<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0]);
So far so good. Let's add template variables to Rose.
Attaching the view
If we do the following in Rose's template:
{{ something }}
Upon creating the component, the variable will not reflect changes. To make it part of the change detection cycle, we need to attachView
to the application reference.
// main.ts
const componentRef = createComponent(RosePartialComponent, {
environmentInjector: this.appRef.injector,
});
// attach view to make it part of change detection cycle
this.appRef.attachView(componentRef.hostView);
document.body.append(
(<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0]
);
Passing inputs
To set input after creation, all we need to do is use setInput
of component reference:
// main.ts
// set inputs
componentRef.setInput('something', 'something else');
// rose component
// create input
@Input() something: string = 'somevalue';
We can also set the value of public properties through the instance
property.
componentRef.instance.something = 'anything else';
We can also call public methods in our instance
the same way. We're going to use that later to try and remove the component from within the component itself.
Detach and destroy.
In order to move on to other routes, especially that we appended directly to the body, we need a way to destroy the component and detach it from the change detection cycle.
// maint.ts
// remove element using its component reference
this.appRef.detachView(componentRef.hostView);
componentRef.destroy();
Emit output
If Rose has an output emitted, the EventEmitter
in Angular is an extension of RxJS Subject
, thus to listen to those events, we can subscribe to them.
// rose.component
// create output
@Output() onSomething: EventEmitter<string> = new EventEmitter<string>();
// in main.ts
// listen to output events
componentRef.instance.onSomething.subscribe((data: string) => {
console.log(data);
});
Put it in
Let me make the removal of the element, from inside the element itself. That should be handy. In Rose, let's add a remove button and attach it to an event.
// rose:
@Component({
template: `
// ... add remove button
<button class="btn-fake" (click)="remove()">Remove</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RosePartialComponent {
// add output
@Output() onRemove: EventEmitter<string> = new EventEmitter<string>();
// emit on click
remove(): void {
this.onRemove.emit('remove element');
}
}
In main component, let's listen to the remove button to remove element. And since we are subscribing, let's also unsubscribe.
// main.ts
insertElement(): void {
// ...
// listen to Output events
const ref = componentRef.instance.onRemove.subscribe((data: string) => {
// remove element, then unsubscribe
this.appRef.detachView(componentRef.hostView);
componentRef.destroy();
ref.unsubscribe();
});
}
With those basic ingredients, let's rewrite our toast service to get rid of the toast element, and make it programmatic.
Putting it to good use. The toast as an example.
In our previous trip to error handling, we created a toast service that controls a single toast element, showing, and hiding it upon request. We can use this method to insert the component programmatically upon initialization of the Toast state service, and that will reduce some code lurking around, specifically the toast host element in the app root. This change is superficial and would not alter any behavior.
When initialized, the toast component injects the same service immediately, causing a circular dependency issue. To avoid that, we have a very simple solution, create the element upon first time Show
.
We shall use a flag to detect the existence of the component.
// services/toast/toast.state
// rewrite
@Injectable({ providedIn: 'root' })
export class Toast extends StateService<IToast> {
// v16 inject application reference
constructor(private appRef: ApplicationRef) {
// ...
}
// v16 use a flag
private created: boolean;
// add component programmatically
private addComponent() {
// v16 check component if it does not exist, create it
if (this.created) {
return;
}
const componentRef = createComponent(ToastPartialComponent, {
environmentInjector: this.appRef.injector
});
this.appRef.attachView(componentRef.hostView);
// append to body
document.body.append((<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0]);
this.created = true;
}
Show(code: string, options?: IToast) {
// v16 add the component
this.addComponent();
// ... eveyrthing else stays the same
}
}
Also remove any reference to ToastPartialComponent
, and gr-toast
. Now that we create the component programmatically, we don't even need the selector. But we do need to turn the component into standalone
, and import its required modules:
// services/toast/toast.partial
// turn it into standalone, and drop the selector
@Component({
standalone: true,
imports: [CommonModule],
// ...
})
export class ToastPartialComponent {
constructor(public toastState: Toast) {}
}
Have a look at it on StackBlitz.
Dialog
Time to make a bigger use of it, let's adapt it to create a dialog service, where Peach is going to be displayed in a Rose component, upon request. Let's sleep on that till next Tuesday, or any day of the week.
Thanks for reading this far, did you miss me?
Top comments (0)