For a long time, Angular has been recognized as the go-to JavaScript framework for enterprise applications, not necessarily for its developer experience, but for how easily it can be extended and maintained. Walking into an Angular project with prior Angular knowledge and getting up to speed quickly is straightforward, since every Angular app shares the same foundational architecture. However, in the last year or so, the Angular team has shifted focus to make the developer experience a central priority, and a significant part of that effort has been simplifying the way dynamic component loading is implemented.
Dynamic Components
Many credit Angular 14 as the starting point of the #AngularRevolution, but the groundwork was laid six months prior with version 13. In Angular 13, the "Ivy Everywhere" initiative took hold as the old View Engine was phased out. With Ivy in place, the boilerplate for dynamic components was significantly reduced, moving from this pattern:
@Component({
template: `
<button (click)="showModal()">Show Modal</ng-template>
<ng-template #payModal></ng-template>
`,
})
export class PaymentPage {
constructor(private readonly factory: ComponentFactoryResolver) {}
@ViewChild('payModal', { static: true, read: ViewContainerRef })
public modalContainer: ViewContainerRef;
showModal() {
this.modalContainer.clear();
const component = this.factory.resolveComponentFactory(PaymentModal);
this.modalContainer.createComponent<PaymentModal>(component);
}
}
to this more concise version:
@Component({
template: `
<button (click)="showModal()">Show Modal</ng-template>
<ng-template #payModal></ng-template>
`,
})
export class PaymentPage {
@ViewChild('payModal', { static: true, read: ViewContainerRef })
public modalContainer: ViewContainerRef;
showModal() {
this.modalContainer.clear();
this.modalContainer.createComponent<PaymentModal>(PaymentModal);
}
}
While the Angular team made great strides in trimming the boilerplate during the Angular 13 release, there is still a fair amount of ceremony involved in dynamically creating a component. For newcomers, or for developers who haven't encountered this pattern before, nailing down the required steps can be a bit tricky.
One thing you must keep in mind is clearing the contents of the ng-template. If a component was previously rendered in that space and you attempt to create a new one, the old content will still be present. The shift from createComponentFactory to simply using createComponent is definitely an improvement, but explaining to a curious developer what's happening beyond the basic idea of "you're creating a component here" can still be challenging. Under the hood, the Ivy renderer is managing the heavy lifting.
Despite these gains, the Angular team pushed the experience even further with the release of Angular 17…
Deferred Components
It's safe to assume I'm not alone in thinking that "deferred components" sound a lot like dynamic components. And truthfully, at the core, a lot of the same processes are taking place. The key DX win with deferred components is that the boilerplate shrinks even more — you no longer need a ViewContainerRef; the renderer takes care of everything on its own.
@Component({
imports: [PaymentModalComponent],
template: `
<button #showModal>Show Modal</button>
@defer (on interaction(showModal)) {
<payment-modal />
}
`,
})
export class PaymentPage {}
The unusual syntax you see in the template is part of the new control flow system. With it, you can instruct the renderer to hold off on loading the content inside the @defer block until the user interacts with the template variable you've named. The @defer block accepts a range of declarative triggers: "interaction," "idle," "timer," "viewport," "hover," and "immediate." Each of those arguments does exactly what it sounds like, offering way more flexibility than you ever got with dynamic components.
For instance, look at my earlier modal example where the modal shows up right after the user presses the "show me" button. I could alter the defer argument to @defer(on interaction(showModal); prefetch on idle) to preload the modal's data before the user even touches the button. This kind of behavior was cumbersome to set up with dynamic components. Furthermore, you can mix and match these triggers to create some fairly creative conditions.
@Component({
imports: [PaymentModalComponent],
template: `
<button #showModal>Show Modal</button>
<button (click)="toggleModals()">Toggle Modals</button>
@defer (on interaction(showModal); when openModals()) {
<payment-modal />
}
`,
})
export class PaymentPage {
public openModals = signal(false);
toggleModals() {
this.openModals.update((val) => !val);
}
}
The possibilities with these triggers are nearly endless, which makes them quite powerful. That said, you can't use the same trigger more than once within the same @defer block.
Deferred Component Caveats
If you were paying close attention, you might have noticed that the Angular 17 deferred loading example includes an imports array with the PaymentModalComponent. As of the time this article was written, the consuming component is required to import the deferred component. This is a departure from the dynamic component approach, where the component is created programmatically. In the deferred scenario, the component is already aware of the injection context, so you don't need all that setup in the component class.
With dynamic components, you were able to set up logical chains much like what you'd do with multiple conditions. The difference with deferred components is that using multiple triggers always behaves as an "OR" rather than an "AND." In my example, I could make the modal load when the user interacts with the button OR when a 5-second timer goes off; but I can't, purely within the template, set up a scenario where both the user has interacted with the button AND the page has been idle for 5 seconds. Instead, for something like that, I could use the more imperative @defer(when …) form, combining various logical conditions and tying it to a signal that updates whenever the value changes.
Another caveat, though a minor one, is that the component you're deferring must be a standalone component. It's also worth noting that if you export anything else from that same file — such as consts, functions, or interfaces — and those references get eagerly imported elsewhere, you'll lose the lazy-loading benefit for that component.
These caveats are probably a minor trade-off given how much the DX improves. The developer experience is far superior, making it easier for both junior and seasoned devs to adopt a more performant workflow (compared to using flags with template data) without the added complexity.
