Angular Programmatic Component Creation: ViewContainerRef.createComponent() for Modals, Toasts, Plugins [2026]

Link copied
Angular Programmatic Component Creation: ViewContainerRef.createComponent() for Modals, Toasts, Plugins [2026]

Angular Programmatic Component Creation: ViewContainerRef.createComponent() for Modals, Toasts, Plugins [2026]

Most Angular components appear in the DOM because some other component's template declares them. But sometimes you need to render a component you do not know about at compile time — a dialog whose type is decided at runtime, a toast notification that fires from a service, a plugin a host app loads dynamically. For those cases Angular gives you ViewContainerRef.createComponent() — the programmatic-rendering API.

This is lesson 2.14 of the Angular Tutorial, closing Module 2. The patterns here are advanced — most application code never needs them. But when you need them (modals, toasts, dynamic forms, plugin systems), nothing else works.

The two ways components reach the DOM #

A quick mental model:

Mechanism When you use it How
Template-driven Static, known-at-compile-time UI <app-card /> in a template
Programmatic Dynamic, runtime-decided UI vcr.createComponent(SomeClass) in code

99% of components are template-driven. The remaining 1% are what this lesson is about.

The minimal example #

Given any component class, you can render it into a ViewContainerRef:

import { Component, ViewContainerRef, inject } from '@angular/core';
import { ToastComponent } from './toast.component';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="showToast()">Show toast</button>
    <ng-container #toastContainer />
  `,
})
export class AppRootComponent {
  private vcr = inject(ViewContainerRef);

  showToast() {
    const ref = this.vcr.createComponent(ToastComponent);
    ref.setInput('message', 'Saved!');
    setTimeout(() => ref.destroy(), 3000);
  }
}

What happened:

  1. inject(ViewContainerRef) gives you the root component's view container.
  2. createComponent(ToastComponent) instantiates a new ToastComponent and appends it.
  3. ref.setInput('message', 'Saved!') sets the new component's input signal.
  4. ref.destroy() removes it from the DOM and cleans up.

The returned ComponentRef<ToastComponent> is the handle for further interaction.

Choosing where to render #

Injecting ViewContainerRef from the component class gives you the enclosing container. To target a specific spot, query a <ng-container> reference:

@Component({
  template: `
    <header>...</header>
    <main>
      <ng-container #modalSlot />
    </main>
  `,
})
export class AppRootComponent {
  modalSlot = viewChild('modalSlot', { read: ViewContainerRef });

  show() {
    const slot = this.modalSlot();
    if (!slot) return;
    const ref = slot.createComponent(SomeDialogComponent);
    // ref lives at the marker position, not at the component root
  }
}

The read: ViewContainerRef option on the signal query is the trick — it gives you the view container at that template position rather than the ElementRef. Now createComponent renders into that exact spot.

Setting inputs and reading outputs #

The ComponentRef exposes typed methods for both:

const ref = vcr.createComponent(ConfirmDialogComponent);
ref.setInput('title', 'Delete user?');
ref.setInput('confirmLabel', 'Delete');

ref.instance.confirmed.subscribe(() => {
  this.deleteUser();
  ref.destroy();
});

ref.instance.cancelled.subscribe(() => ref.destroy());

For outputs, you can subscribe directly to ComponentRef.instance.outputName — even though the modern output() doesn't expose .subscribe() on its own, ComponentRef exposes a compatible interface for programmatically-created components.

Lifecycle of a dynamically-created component #

Creating + destroying:

const ref = vcr.createComponent(MyComponent);     // Component instantiated; CD runs
// ... user interacts ...
ref.destroy();                                      // Component removed from DOM, ngOnDestroy fires

No other lifecycle differences. The component goes through the full lifecycle (constructor, effect() setup, afterNextRender, etc.) exactly like a template-rendered one.

If the host component is destroyed before the dynamic child, Angular cleans up both. So programmatic components NEVER leak — the worst case is they survive too long until the host dies.

A complete pattern: a Modal service #

The classic use case — a service that opens modals from anywhere:

import { ApplicationRef, ComponentRef, EnvironmentInjector, Injectable, Type, createComponent, inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ModalService {
  private appRef = inject(ApplicationRef);
  private injector = inject(EnvironmentInjector);

  open<T extends object>(component: Type<T>, inputs: Partial<T> = {}): ComponentRef<T> {
    // Create the component using the root-level createComponent helper
    const ref = createComponent(component, {
      environmentInjector: this.injector,
    });

    // Apply inputs
    for (const [k, v] of Object.entries(inputs)) {
      ref.setInput(k, v);
    }

    // Attach to the application — this triggers change detection
    this.appRef.attachView(ref.hostView);

    // Append the host element to the DOM
    document.body.appendChild(ref.location.nativeElement);

    // Provide a cleanup helper
    const originalDestroy = ref.destroy.bind(ref);
    ref.destroy = () => {
      this.appRef.detachView(ref.hostView);
      originalDestroy();
    };

    return ref;
  }
}

Usage from any component:

const ref = this.modals.open(ConfirmDialogComponent, {
  title: 'Delete user?',
  body: 'This cannot be undone.',
  confirmLabel: 'Delete',
});
ref.instance.confirmed.subscribe(() => {
  this.deleteUser();
  ref.destroy();
});

This pattern — services that open components via the DOM — underpins every Angular modal/toast/snackbar library. Material's MatDialog, PrimeNG's DialogService, ng-bootstrap's NgbModal are all variations on this.

createComponent() vs ViewContainerRef.createComponent() #

Two APIs do similar things:

API Use when
ViewContainerRef.createComponent(Cmp) You have a specific anchor point in a template (a <ng-container #x>). Inserts AT that location.
createComponent(Cmp, { environmentInjector, ... }) You are NOT in a template (e.g., a service). Creates standalone — you handle DOM insertion.

For in-template insertion, prefer ViewContainerRef. For service-from-anywhere modals, prefer the bare createComponent().

Dynamic component selection by data #

A common pattern — render different components depending on data:

type PluginConfig =
  | { type: 'chart'; data: ChartData }
  | { type: 'table'; data: TableData }
  | { type: 'image'; src: string };

const componentMap = {
  chart: ChartPluginComponent,
  table: TablePluginComponent,
  image: ImagePluginComponent,
};

@Component({
  template: `<ng-container #host />`,
})
export class PluginRendererComponent {
  config = input.required<PluginConfig>();
  host = viewChild('host', { read: ViewContainerRef });

  private currentRef?: ComponentRef<unknown>;

  constructor() {
    effect(() => {
      this.currentRef?.destroy();
      const slot = this.host();
      if (!slot) return;
      const cfg = this.config();
      const Cmp = componentMap[cfg.type];
      this.currentRef = slot.createComponent(Cmp);
      for (const [k, v] of Object.entries(cfg)) {
        if (k !== 'type') this.currentRef.setInput(k, v);
      }
    });
  }
}

The effect destroys the previous instance, looks up the right component class by type, instantiates it, and pipes the config inputs in. This is the cleanest pattern for plugin systems where the component class is decided at runtime.

When you do NOT need programmatic creation #

Three cases that look like they need it but don't:

  1. Conditional rendering@if (showModal()) { <app-modal ... /> } is simpler than programmatic creation
  2. List of same component@for (item of items()) { <app-card [item]="item" /> } handles this natively
  3. Switch between known components@switch (type) { @case ('chart') { <app-chart /> } @case ('table') { <app-table /> } } is more readable than a component map

Reach for createComponent only when the component class itself is dynamic (unknown at template authoring time) or when the rendering location is outside any normal template (modals attached to document.body).

Common gotchas #

Symptom Cause Fix
Created component doesn't appear Forgot to call appRef.attachView(ref.hostView) AND insert the element into the DOM Both steps are needed — check both
Inputs don't apply Used ref.instance.input = value instead of ref.setInput('input', value) setInput is required to fire the input signal correctly
Change detection doesn't run Component is detached from the application appRef.attachView() is what enrolls it in change detection
Memory leak when component dies Forgot to call ref.destroy() when the user dismisses the modal Always pair create with destroy — usually in the close/cancel handlers
createComponent throws "No EnvironmentInjector" Called the bare createComponent without { environmentInjector } Provide one: inject(EnvironmentInjector) from the calling service

What's next #

This closes Module 2 — Components & Templates. You can now write components in every shape Angular supports: from a 20-line <app-counter> with one input, all the way to a plugin-system component that decides what to render at runtime.

Module 3 picks up the signal system in depth — producer/consumer graph, effect() vs computed(), model(), linkedSignal(), zoneless change detection internals. Inputs, queries, and outputs are all signals; understanding the system unlocks the rest of Angular.

Try it yourself #

A tiny toast service in 40 lines:

// toast.service.ts
import { ApplicationRef, ComponentRef, createComponent, EnvironmentInjector, Injectable, inject } from '@angular/core';
import { ToastComponent } from './toast.component';

@Injectable({ providedIn: 'root' })
export class ToastService {
  private appRef = inject(ApplicationRef);
  private injector = inject(EnvironmentInjector);

  show(message: string, duration = 3000) {
    const ref = createComponent(ToastComponent, { environmentInjector: this.injector });
    ref.setInput('message', message);
    this.appRef.attachView(ref.hostView);
    document.body.appendChild(ref.location.nativeElement);
    setTimeout(() => {
      this.appRef.detachView(ref.hostView);
      ref.destroy();
    }, duration);
  }
}

// toast.component.ts
@Component({
  selector: 'app-toast',
  template: `<div class="toast">{{ message() }}</div>`,
  styles: `.toast { position: fixed; bottom: 24px; right: 24px; background: #333; color: white; padding: 12px 18px; border-radius: 6px; }`,
})
export class ToastComponent {
  message = input.required<string>();
}

// Anywhere in the app:
this.toastService.show('Saved successfully!');

Three files, one method, and you have a service that pops a styled toast from anywhere in the app — no template wiring required. The pattern scales up to modals, sidebars, command palettes, and any other always-on-top UI.

YouI’m building a modal. Should I use Angular Material’s MatDialog or roll my own with createComponent?
Claude · used get_best_practicesFor most apps, use Angular Material’s MatDialog (or the CDK Overlay primitives that MatDialog wraps). It already handles focus trapping, ARIA roles, keyboard escape, click-outside, scroll lock, and overlay layering — all the accessibility and UX details that take days to reimplement. Roll your own with createComponent only if you need a very specific behavior the CDK doesn’t support, or you are building a UI library yourself. For the latter, the patterns in this lesson are the foundation.

That closes Module 2. Module 3 (Signals & Reactivity) picks up next.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied When Angular is launched ? When Angular Landed: A History of the Popular Web Framework # Angular, …

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *