Angular Advanced #3 Dynamic components and ViewContainerRef

10 min read

So far almost every component we’ve touched has been statically embedded in a template. As in Basics #2, when the parent template writes the child’s slot directly with <app-user-card></app-user-card>, the Angular compiler reads that and builds the tree.

In real-world apps, though, this approach doesn’t hold up everywhere. Modals, toasts, tooltips, dashboards that show widgets the user picked, screens that plugins register dynamically — these are places where you don’t know when, where, or how many components will appear at compile time. The runtime has to say “show this component here, now.”

In this post we’ll do a full lap of the tools modern Angular gives you for dynamic components. From low-level APIs like ViewContainerRef/createComponent to higher-level tools like @defer, ngComponentOutlet, and CDK Portal, and we’ll wrap up by building a small toast service.

Where dynamic components fit #

Let’s list typical candidates.

  • Modals/dialogs — to escape a parent’s overflow or z-index, it’s easier to send them outside the DOM tree (directly under <body>)
  • Toasts/notifications — wherever you call from, they should briefly appear in a corner of the screen
  • Tooltips/popovers — anchored to the trigger element, usually one at a time
  • Widgets/plugin systems — admins picking widgets, or external plugins registering components
  • Wizards — flows where each step is a totally different component

The common thread is “static templates can’t solve this.” So Angular provides several layers of tools that leave a slot in the template and fill it from code.

ViewContainerRef — the slot #

The most basic concept is ViewContainerRef. As the name suggests, it is a reference to a container where a view goes. Usually you put a marker element in the template and grab the reference with the signal-based viewChild().

src/app/host.component.ts
import { Component, ViewContainerRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <h2>The dynamic component goes here</h2>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });
}

<ng-container #slot> is a marker that paints nothing on screen. read: ViewContainerRef is the option that says “read that slot as a ViewContainerRef.”

Note
In older code you’ll see the @ViewChild('slot', { read: ViewContainerRef }) slot!: ViewContainerRef; form. The behavior is identical, but the signal-based viewChild() has clearer timing, so for new code the signal form is recommended. Also, if you put the variable on a regular element (<div #slot>) instead of ng-container, the dynamic component goes as a sibling of that element — not “inside” it.

Creating directly with createComponent #

Once you have the slot, the next step is making and inserting the component there. ViewContainerRef.createComponent() is the simplest way.

src/app/alert.component.ts
@Component({
  selector: 'app-alert',
  standalone: true,
  template: `
    <strong>{{ title() }}</strong>
    <p>{{ message() }}</p>
    <button (click)="dismiss.emit()">Close</button>
  `,
})
export class AlertComponent {
  title = input.required<string>();
  message = input.required<string>();
  dismiss = output<void>();
}

Let’s pop this up dynamically from the host.

src/app/host.component.ts
@Component({
  selector: 'app-host',
  standalone: true,
  template: `
    <button (click)="showAlert()">Show alert</button>
    <ng-container #slot></ng-container>
  `,
})
export class HostComponent {
  slot = viewChild('slot', { read: ViewContainerRef });

  showAlert() {
    const container = this.slot();
    if (!container) return;

    container.clear();
    const ref = container.createComponent(AlertComponent);
    ref.setInput('title', 'Save failed');
    ref.setInput('message', 'Please check your network connection.');
    ref.instance.dismiss.subscribe(() => ref.destroy());
  }
}

The flow is simple — clear() empties the slot, createComponent creates the instance, setInput injects inputs, and you subscribe to the output (dismiss) and call destroy() on close to clean up. setInput works well with signal inputs and triggers change detection safely, so instead of poking the instance directly with ref.instance.title.set(...), using setInput is the standard.

The ComponentFactoryResolver you might see in old code is deprecated. Since v13, calling createComponent directly is the way, so you don’t need to worry about it in new code.

@defer block — the most modern approach #

When you want to lazily mount part of a screen instead of a whole route, the @defer block in Angular 17+ is the cleanest. You get the effect of dynamic creation while staying declarative in the template.

src/app/dashboard.component.html
<h1>Dashboard</h1>

@defer (on viewport) {
  <app-heavy-chart [data]="metrics()" />
} @placeholder {
  <div class="placeholder">Chart area</div>
} @loading (after 100ms; minimum 500ms) {
  <div>Preparing the chart...</div>
} @error {
  <div>Failed to load the chart.</div>
}

@defer (on viewport) means “when this block enters the viewport, fetch the chunk and instantiate.” Triggers include on viewport, on idle (default), on timer(2s), on interaction, on hover, and when condition().

The build tool automatically splits the component imports inside @defer into a separate chunk. Think of it as the in-screen version of route-level lazy loading. Charts, heavy editors, video players — anything that “doesn’t have to be visible from the start but might eventually” — these are the top candidates. If @defer is enough, there’s no reason to reach for low-level APIs like createComponent; only step down to that layer when you really need imperative control (modals, toasts, etc.).

ngComponentOutlet — swapping classes from the template #

“The component to mount is decided at runtime, but I want the slot to live inside the template” — for that, NgComponentOutlet fits. It shows up often for wizard steps, user-picked widgets, A/B-split forms, and so on.

src/app/wizard.component.ts
@Component({
  selector: 'app-wizard',
  standalone: true,
  imports: [NgComponentOutlet],
  template: `
    <ng-container *ngComponentOutlet="currentStep(); inputs: stepInputs()" />
    <button (click)="next()">Next</button>
  `,
})
export class WizardComponent {
  private steps: Type<unknown>[] = [Step1Component, Step2Component, Step3Component];
  private index = signal(0);

  currentStep = () => this.steps[this.index()];
  stepInputs = () => ({ stepIndex: this.index() });

  next() {
    this.index.update(i => Math.min(i + 1, this.steps.length - 1));
  }
}

Pass the component class itself to ngComponentOutlet and an instance is mounted there. Switching the class auto-destroys the previous instance and mounts the new one. You can pass inputs as well. It’s a hair less expressive than createComponent, but it stays declarative in the template, the code is shorter, and for the simple case of “only the type of component in this slot changes,” it’s the first pick.

CDK Portal — sending it outside the parent tree #

Embedding a modal or overlay inside the host tree easily breaks under a parent’s overflow: hidden, gets buried by z-index conflicts, or sees fixed positioning fail because of an ancestor with transform. The clean tool for this is the Portal in Angular CDK. It renders the component at a different location (typically directly under <body>) instead of where it was created.

Install CDK
npm install @angular/cdk
src/app/modal-host.component.ts
@Component({
  selector: 'app-modal-host',
  standalone: true,
  template: `<button (click)="openSettings()">Open settings</button>`,
})
export class ModalHostComponent {
  private overlay = inject(Overlay);

  openSettings() {
    const overlayRef = this.overlay.create({
      hasBackdrop: true,
      positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
    });

    overlayRef.attach(new ComponentPortal(SettingsModalComponent));
    overlayRef.backdropClick().subscribe(() => overlayRef.dispose());
  }
}

Overlay mounts the component into a separate DOM area managed by CDK (cdk-overlay-container), and handles position strategy, backdrop, and keyboard handling all at once. Building a modal directly with createComponent costs time on details like backdrop, focus trap, ESC-to-close, and position calculation, but CDK Portal/Overlay is a library that has those details already worked out. Modals, dropdowns, tooltips — it’s effectively the standard tool for almost any place that needs to be sent outside the parent tree.

Dynamic injector — different context per component #

Sometimes you want to inject different service instances into each dynamically mounted component. The same modal, but its “close” behavior should differ depending on the caller. createComponent accepts an injector option, so you can build an inline injector with Injector.create() and pass it.

src/app/host.component.ts
export class DialogContext {
  constructor(public title: string, public onConfirm: () => void) {}
}

export class HostComponent {
  private parentInjector = inject(Injector);
  slot = viewChild('slot', { read: ViewContainerRef });

  open() {
    const container = this.slot();
    if (!container) return;

    const dialogInjector = Injector.create({
      providers: [{
        provide: DialogContext,
        useValue: new DialogContext('Really delete?', () => this.handleConfirm()),
      }],
      parent: this.parentInjector,
    });

    container.createComponent(ConfirmDialogComponent, { injector: dialogInjector });
  }

  private handleConfirm() { /* actual delete logic */ }
}

ConfirmDialogComponent only needs inject(DialogContext), and a different caller injects a different context into the same component every time. CDK’s MatDialog uses the same pattern internally, and the MAT_DIALOG_DATA token is exactly this slot.

Memory management #

Dynamic components are powerful, but you also have to handle cleanup yourself. The basic move is ComponentRef.destroy() — the component’s OnDestroy runs and the child tree is destroyed along with it. To clear all dynamic views in a ViewContainerRef at once, use clear().

When the host component is destroyed, children created via ViewContainerRef.createComponent are connected to the host’s view tree and get cleaned up automatically. Components sent outside the parent tree, like CDK Overlay, do not disappear automatically when the host is gone — you must call overlayRef.dispose() directly, or tie it to the host’s destroy timing with DestroyRef.

Tying cleanup with DestroyRef
import { DestroyRef, inject } from '@angular/core';

const destroyRef = inject(DestroyRef);
const overlayRef = this.overlay.create(/* ... */);
destroyRef.onDestroy(() => overlayRef.dispose());
Tip
If you subscribe to an output, that Subscription also has to be cleaned up with the host. For a one-shot modal, destroying on close is enough, but for a host that mounts dynamic components multiple times during its lifetime, every Subscription you create needs to be reclaimed properly to avoid leaks.

Practice: a toast service #

Let’s bundle the tools we’ve seen into a small toast service. From anywhere, you call toast.show('Saved') and a message briefly appears in the top-right corner — that familiar toast. First, the component that draws a single toast.

src/app/toast/toast.component.ts
export type ToastVariant = 'info' | 'success' | 'error';

@Component({
  selector: 'app-toast',
  standalone: true,
  template: `
    <div class="toast" [class]="variant()">
      <span>{{ message() }}</span>
      <button (click)="dismiss.emit()">×</button>
    </div>
  `,
})
export class ToastComponent {
  message = input.required<string>();
  variant = input<ToastVariant>('info');
  dismiss = output<void>();
}

The service makes a container in the app once, and dynamically adds toast components to it on every call.

src/app/toast/toast.service.ts
@Injectable({ providedIn: 'root' })
export class ToastService {
  private appRef = inject(ApplicationRef);
  private injector = inject(EnvironmentInjector);
  private container: HTMLElement | null = null;

  show(message: string, variant: ToastVariant = 'info', duration = 3000) {
    const host = this.ensureContainer();

    const ref = createComponent(ToastComponent, {
      hostElement: document.createElement('div'),
      environmentInjector: this.injector,
    });
    ref.setInput('message', message);
    ref.setInput('variant', variant);

    host.appendChild(ref.location.nativeElement);
    this.appRef.attachView(ref.hostView);

    const close = () => {
      this.appRef.detachView(ref.hostView);
      ref.destroy();
    };
    ref.instance.dismiss.subscribe(close);
    setTimeout(close, duration);
  }

  private ensureContainer(): HTMLElement {
    if (this.container) return this.container;
    const el = document.createElement('div');
    el.style.cssText = 'position:fixed; top:16px; right:16px; z-index:9999;';
    document.body.appendChild(el);
    return (this.container = el);
  }
}

Here we used the standalone createComponent function instead of ViewContainerRef. It can build a component without a host component, so it fits when you instantiate directly inside a service. We attach the resulting ComponentRef to the change detection tree with ApplicationRef.attachView(), and insert it into the DOM directly with appendChild. Calling it is a one-liner: this.toast.show('Saved.', 'success').

This example is intentionally small. In production, more details would go in — animations, queue length limits, message dedup, keyboard accessibility — but the core skeleton is this. Building it on top of CDK Overlay handles position/backdrop/stacking more cleanly, so in practice, before writing your own, look at putting it on top of CDK first.

Wrap-up #

In this post we did a lap around the dynamic component patterns of modern Angular. To summarize:

  • ViewContainerRef is the slot, createComponent is the insert, and setInput injects inputs
  • @defer is the most modern tool for in-screen lazy. Consider it first when possible
  • ngComponentOutlet is a declarative way to swap classes from the template
  • For modals/overlays, the standard is mounting outside the parent tree with CDK Portal/Overlay
  • Inject different contexts per component with a dynamic injector
  • Memory cleanup is on you — destroy()/clear()/dispose() (ComponentFactoryResolver is deprecated)

Most real-world cases are solved with @defer and ngComponentOutlet, and modals/toasts go to CDK Overlay. Calling createComponent directly is rare, but knowing the shape pays off when building libraries or debugging deep places.

In the next post — “Angular Advanced #4 RxJS in depth” — we’ll seriously cover the other axis of async data flow: Observables, operators, multicasting, error handling, and the boundary with Signals. When dynamic components meet RxJS, patterns like toast queues, modal stacks, and search autocomplete get a lot cleaner.

X