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 |
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.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.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()">×</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) |