Dynamic Components with ViewContainerRef

Most Angular components are declared statically in templates โ€” you write <app-task-card> and Angular renders it. But some UI patterns require creating components programmatically at runtime: a notification system that creates toast components on demand, a dialog service that creates modals from anywhere in the application, a plugin system that loads different component types based on configuration. Dynamic component creation with ViewContainerRef is Angular’s mechanism for this โ€” you create a component instance programmatically, attach it to the DOM, pass inputs, and destroy it when done.

ViewContainerRef API

Method / Property Purpose
createComponent(ComponentType) Create and attach a component to this container
createEmbeddedView(templateRef, context) Render a TemplateRef with optional data context
insert(viewRef, index?) Insert an existing view at an optional position
remove(index?) Remove and destroy a view at an index
clear() Remove and destroy all views in the container
get(index) Get a view by index
indexOf(viewRef) Get the index of a view
move(viewRef, index) Move a view to a new position
length Number of views currently in the container

ComponentRef API โ€” Working with Created Components

Property / Method Purpose
instance The component class instance โ€” set inputs, call methods
hostView The ViewRef of the host โ€” can be inserted into other containers
location ElementRef of the component’s host element
setInput(name, value) Set a signal or decorator input by name (Angular 14+)
changeDetectorRef Trigger change detection manually on this component
destroy() Destroy the component instance and remove from DOM
Note: Use componentRef.setInput('inputName', value) (Angular 14+) rather than directly assigning componentRef.instance.inputName = value when the component uses signal inputs. setInput() correctly triggers Angular’s change detection and updates signal input values. Directly assigning to a signal input property bypasses the signal mechanism and the view does not update. For traditional @Input() properties, direct assignment works but setInput() is still preferred for consistency and change detection triggering.
Tip: For application-wide services like toast notifications or confirmation dialogs, inject ViewContainerRef from the ApplicationRef rather than from a specific component. This attaches the dynamic component to the root application view, ensuring it renders on top of all other content regardless of which component triggered it. Alternatively, use Angular CDK’s Overlay service, which provides a pre-built portal layer specifically designed for this use case.
Warning: Always destroy dynamically created components when they are no longer needed. Calling componentRef.destroy() removes the component from the DOM, unsubscribes its subscriptions, calls its ngOnDestroy(), and frees the memory. Forgetting to destroy creates memory leaks โ€” each dynamically created notification, dialog, or tooltip that is never destroyed accumulates until the application crashes or becomes sluggish. Always pair createComponent() with a cleanup strategy.

Complete Dynamic Component Examples

// โ”€โ”€ Toast notification service with dynamic components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// shared/models/toast.model.ts
export interface ToastConfig {
    message:  string;
    type:     'success' | 'error' | 'info' | 'warning';
    duration?: number;
    title?:   string;
}

// shared/components/toast/toast.component.ts
import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastConfig } from '../../models/toast.model';

@Component({
    selector:   'app-toast',
    standalone: true,
    imports:    [CommonModule],
    template: `
        <div class="toast toast--{{ config.type }}" role="alert">
            <strong *ngIf="config.title">{{ config.title }}</strong>
            <p>{{ config.message }}</p>
            <button (click)="close()">&times;</button>
        </div>
    `,
    styles: [`
        .toast { display: flex; align-items: center; gap: 0.5rem; padding: 1rem;
                 border-radius: 8px; min-width: 300px; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
        .toast--success { background: #dcfce7; border-left: 4px solid #16a34a; }
        .toast--error   { background: #fee2e2; border-left: 4px solid #dc2626; }
        .toast--info    { background: #dbeafe; border-left: 4px solid #2563eb; }
        .toast--warning { background: #fef3c7; border-left: 4px solid #d97706; }
    `],
})
export class ToastComponent implements OnInit {
    @Input() config!: ToastConfig;
    @Output() dismissed = new EventEmitter<void>();

    ngOnInit(): void {
        if (this.config.duration !== 0) {
            setTimeout(() => this.close(), this.config.duration ?? 4000);
        }
    }

    close(): void { this.dismissed.emit(); }
}

// core/services/toast.service.ts
import {
    Injectable, ApplicationRef, createComponent,
    EnvironmentInjector, ComponentRef, inject,
} from '@angular/core';
import { ToastComponent } from '../../shared/components/toast/toast.component';
import { ToastConfig }    from '../../shared/models/toast.model';

@Injectable({ providedIn: 'root' })
export class ToastService {
    private appRef            = inject(ApplicationRef);
    private environmentInjector = inject(EnvironmentInjector);
    private activeToasts: ComponentRef<ToastComponent>[] = [];

    show(config: ToastConfig): ComponentRef<ToastComponent> {
        // Create the component
        const toastRef = createComponent(ToastComponent, {
            environmentInjector: this.environmentInjector,
        });

        // Set inputs
        toastRef.setInput('config', config);

        // Listen to the dismissed output
        toastRef.instance.dismissed.subscribe(() => this.remove(toastRef));

        // Attach to Angular's change detection
        this.appRef.attachView(toastRef.hostView);

        // Append to the DOM
        const toastEl = (toastRef.hostView as any).rootNodes[0] as HTMLElement;
        this.getContainer().appendChild(toastEl);

        this.activeToasts.push(toastRef);
        return toastRef;
    }

    remove(toastRef: ComponentRef<ToastComponent>): void {
        const index = this.activeToasts.indexOf(toastRef);
        if (index === -1) return;

        // Detach from change detection
        this.appRef.detachView(toastRef.hostView);
        // Destroy the component
        toastRef.destroy();
        // Remove from tracking array
        this.activeToasts.splice(index, 1);
    }

    success(message: string, title?: string): void {
        this.show({ type: 'success', message, title });
    }
    error  (message: string, title?: string): void {
        this.show({ type: 'error',   message, title });
    }
    info   (message: string): void { this.show({ type: 'info', message }); }

    private getContainer(): HTMLElement {
        let container = document.getElementById('toast-container');
        if (!container) {
            container = document.createElement('div');
            container.id = 'toast-container';
            container.style.cssText = `
                position: fixed; top: 1rem; right: 1rem;
                display: flex; flex-direction: column; gap: 0.5rem; z-index: 9999;
            `;
            document.body.appendChild(container);
        }
        return container;
    }
}

// โ”€โ”€ Dynamic component with ViewContainerRef in a component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// A panel that renders different widgets based on configuration

// Type for polymorphic component loading
type WidgetType = 'task-stats' | 'recent-tasks' | 'overdue-tasks';

interface WidgetConfig {
    type:   WidgetType;
    inputs: Record<string, unknown>;
}

@Component({
    selector:   'app-dashboard',
    standalone: true,
    template: `
        <div class="dashboard-grid">
            <!-- Anchor element for dynamic components -->
            <ng-container #widgetContainer></ng-container>
        </div>
    `,
})
export class DashboardComponent implements OnInit, AfterViewInit {
    @ViewChild('widgetContainer', { read: ViewContainerRef })
    widgetContainer!: ViewContainerRef;

    private widgets: WidgetConfig[] = [
        { type: 'task-stats',    inputs: { userId: 'me' } },
        { type: 'recent-tasks',  inputs: { limit: 5 } },
        { type: 'overdue-tasks', inputs: { limit: 3 } },
    ];

    async ngAfterViewInit(): Promise {
        for (const config of this.widgets) {
            await this.loadWidget(config);
        }
    }

    private async loadWidget(config: WidgetConfig): Promise<void> {
        // Lazy load the component based on type
        const componentMap: Record<WidgetType, () => Promise<any>> = {
            'task-stats':    () => import('./widgets/task-stats.widget').then(m => m.TaskStatsWidget),
            'recent-tasks':  () => import('./widgets/recent-tasks.widget').then(m => m.RecentTasksWidget),
            'overdue-tasks': () => import('./widgets/overdue-tasks.widget').then(m => m.OverdueTasksWidget),
        };

        const ComponentClass = await componentMap[config.type]();
        const componentRef   = this.widgetContainer.createComponent(ComponentClass);

        // Set all inputs
        Object.entries(config.inputs).forEach(([key, value]) => {
            componentRef.setInput(key, value);
        });
    }

    ngOnDestroy(): void {
        this.widgetContainer.clear();  // destroys all dynamic components
    }
}

How It Works

Step 1 โ€” ViewContainerRef Is the Attachment Point

A ViewContainerRef represents a location in the DOM where views (either embedded views from templates or host views from components) can be inserted. Every component and directive has an implicit ViewContainerRef associated with its host element. You can get the container for a specific template location with @ViewChild('ref', { read: ViewContainerRef }). Dynamically created components are attached as children of this container in the DOM.

Step 2 โ€” createComponent() Instantiates the Full Component Lifecycle

When you call viewContainerRef.createComponent(MyComponent) or the standalone createComponent(MyComponent, { environmentInjector }), Angular creates a new component instance, runs its constructor and DI resolution, and renders its template. The component goes through its full lifecycle (ngOnInit, ngAfterViewInit, etc.) just like any statically declared component. The component’s change detection is attached to Angular’s tree from this point.

Step 3 โ€” setInput() Is the Safe Way to Pass Data

componentRef.setInput('name', value) sets an input on the dynamically created component and triggers change detection for that component. It works for both traditional @Input() and signal-based input(). Directly assigning componentRef.instance.name = value works for @Input() but does not trigger Angular’s change detection and does not update signal inputs. Always prefer setInput() for programmatically created components.

Step 4 โ€” appRef.attachView() Connects to Change Detection

When using the root ApplicationRef.createComponent() approach (for application-level services like toasts), the created component is not automatically part of Angular’s change detection tree. You must call appRef.attachView(componentRef.hostView) to connect it. Without this, the component renders once but never updates when its state changes. viewContainerRef.createComponent() (for in-template dynamic components) automatically handles this.

Step 5 โ€” Lazy Loading Components Reduces Initial Bundle

Dynamic import (import('./widget').then(m => m.Widget)) combined with createComponent() enables true on-demand loading. The widget’s JavaScript bundle is only downloaded when the widget is actually needed โ€” not on initial page load. This is the mechanism Angular’s router uses for lazy-loaded routes, but it can be applied to any component. For a dashboard with 10 widgets, only the widgets visible in the initial viewport need to be downloaded first.

Common Mistakes

Mistake 1 โ€” Forgetting to destroy dynamic components

โŒ Wrong โ€” component never destroyed, memory leak:

showToast(config: ToastConfig): void {
    const ref = this.vcr.createComponent(ToastComponent);
    ref.setInput('config', config);
    // Never destroyed โ€” stays in memory forever!
}

✅ Correct โ€” always pair creation with destruction:

showToast(config: ToastConfig): void {
    const ref = this.vcr.createComponent(ToastComponent);
    ref.setInput('config', config);
    ref.instance.dismissed.subscribe(() => ref.destroy());  // clean up on dismiss
    setTimeout(() => ref.destroy(), config.duration ?? 4000);   // or auto-destroy
}

Mistake 2 โ€” Using instance.property instead of setInput for signal inputs

โŒ Wrong โ€” signal input not updated, view not refreshed:

const ref = this.vcr.createComponent(TaskCardComponent);
ref.instance.task = newTask;  // direct assignment doesn't update signal input!
// Template: task() never changes โ€” still shows old task

✅ Correct โ€” use setInput() for both decorator and signal inputs:

const ref = this.vcr.createComponent(TaskCardComponent);
ref.setInput('task', newTask);  // triggers signal update + change detection

Mistake 3 โ€” Not calling appRef.attachView when using createComponent outside VCR

โŒ Wrong โ€” component renders once but never updates:

const ref = createComponent(ToastComponent, { environmentInjector: this.injector });
ref.setInput('config', config);
document.body.appendChild((ref.hostView as any).rootNodes[0]);
// Component does not update because it's not in Angular's change detection tree

✅ Correct โ€” attach to ApplicationRef for change detection:

const ref = createComponent(ToastComponent, { environmentInjector: this.injector });
this.appRef.attachView(ref.hostView);   // connect to change detection tree
document.body.appendChild((ref.hostView as any).rootNodes[0]);

Quick Reference

Task Code
Get VCR in component @ViewChild('anchor', { read: ViewContainerRef }) vcr!: ViewContainerRef
Create component const ref = this.vcr.createComponent(MyComponent)
Set input ref.setInput('taskId', id)
Listen to output ref.instance.saved.subscribe(data => { ... })
Destroy component ref.destroy()
Clear all this.vcr.clear()
App-level create createComponent(Comp, { environmentInjector })
Attach to CD tree this.appRef.attachView(ref.hostView)
Append to DOM document.body.appendChild((ref.hostView as any).rootNodes[0])
Lazy load component const C = await import('./comp').then(m => m.MyComp)

🧠 Test Yourself

A toast service creates a ToastComponent using createComponent(ToastComponent, { environmentInjector }) and appends it to document.body. The toast never animates or updates after appearing. What is missing?