Dynamic Components Overview
Estimated Time: 2 hours | Difficulty: Advanced | Prerequisites: Components, Dependency Injection
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Dynamic Component Lifecycle │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Define Component 2. Get Reference 3. Create │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│
│ │ @Component({ │ │ ViewContainerRef │ │ createComponent │
│ │ template:... │ ───► │ or │ ───► │ () ││
│ │ }) │ │ ViewChild │ │ ││
│ │ class Dynamic │ │ │ │ ││
│ └─────────────────┘ └─────────────────┘ └─────────────────┘│
│ │ │
│ 6. Destroy 5. Interact 4. Set Inputs │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│
│ │ componentRef │ │ Subscribe to │ │ setInput() ││
│ │ .destroy() │ ◄─── │ outputs │ ◄─── │ or ││
│ │ │ │ │ │ instance.prop ││
│ └─────────────────┘ └─────────────────┘ └─────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────────────┘
Creating Dynamic Components
Using ViewContainerRef
Copy
// alert.component.ts
@Component({
selector: 'app-alert',
standalone: true,
template: `
<div class="alert" [class]="'alert-' + type()">
<span class="icon">{{ icon() }}</span>
<span class="message">{{ message() }}</span>
<button class="close" (click)="closed.emit()">×</button>
</div>
`,
styles: `
.alert {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.alert-success { background: #d4edda; color: #155724; }
.alert-error { background: #f8d7da; color: #721c24; }
.alert-warning { background: #fff3cd; color: #856404; }
.alert-info { background: #d1ecf1; color: #0c5460; }
`
})
export class AlertComponent {
type = input<'success' | 'error' | 'warning' | 'info'>('info');
message = input.required<string>();
icon = input('ℹ️');
closed = output<void>();
}
// alert-container.component.ts
@Component({
selector: 'app-alert-container',
standalone: true,
template: `
<div class="alert-container">
<ng-container #alertContainer />
</div>
`
})
export class AlertContainerComponent {
@ViewChild('alertContainer', { read: ViewContainerRef })
container!: ViewContainerRef;
private alerts = new Map<ComponentRef<AlertComponent>, number>();
showAlert(config: AlertConfig) {
const componentRef = this.container.createComponent(AlertComponent);
// Set inputs using setInput (Angular 14+)
componentRef.setInput('type', config.type);
componentRef.setInput('message', config.message);
componentRef.setInput('icon', config.icon ?? this.getDefaultIcon(config.type));
// Subscribe to outputs
componentRef.instance.closed.subscribe(() => {
this.removeAlert(componentRef);
});
// Auto-dismiss
if (config.duration !== 0) {
const timeout = setTimeout(() => {
this.removeAlert(componentRef);
}, config.duration ?? 5000);
this.alerts.set(componentRef, timeout);
}
return componentRef;
}
private removeAlert(ref: ComponentRef<AlertComponent>) {
const timeout = this.alerts.get(ref);
if (timeout) clearTimeout(timeout);
ref.destroy();
this.alerts.delete(ref);
}
private getDefaultIcon(type: string): string {
const icons: Record<string, string> = {
success: '✅',
error: '❌',
warning: '⚠️',
info: 'ℹ️'
};
return icons[type] ?? 'ℹ️';
}
}
interface AlertConfig {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
icon?: string;
duration?: number;
}
NgComponentOutlet
Copy
// Simple dynamic component rendering
@Component({
selector: 'app-dynamic-host',
standalone: true,
imports: [NgComponentOutlet],
template: `
<ng-container *ngComponentOutlet="currentComponent()" />
<!-- With inputs (Angular 16+) -->
<ng-container
*ngComponentOutlet="currentComponent(); inputs: componentInputs()"
/>
<!-- With injector -->
<ng-container
*ngComponentOutlet="currentComponent(); injector: customInjector"
/>
`
})
export class DynamicHostComponent {
currentComponent = signal<Type<any>>(DefaultComponent);
componentInputs = signal<Record<string, unknown>>({});
customInjector = Injector.create({
providers: [
{ provide: CUSTOM_TOKEN, useValue: 'custom value' }
],
parent: inject(Injector)
});
async loadComponent(name: string) {
switch (name) {
case 'chart':
const { ChartComponent } = await import('./chart.component');
this.currentComponent.set(ChartComponent);
this.componentInputs.set({ data: this.chartData });
break;
case 'table':
const { TableComponent } = await import('./table.component');
this.currentComponent.set(TableComponent);
this.componentInputs.set({ rows: this.tableData });
break;
}
}
}
Modal Service Pattern
Copy
// modal.service.ts
@Injectable({ providedIn: 'root' })
export class ModalService {
private viewContainerRef!: ViewContainerRef;
private activeModals = new Map<string, ComponentRef<ModalWrapperComponent>>();
setContainer(vcr: ViewContainerRef) {
this.viewContainerRef = vcr;
}
open<T, R = any>(
component: Type<T>,
config?: ModalConfig<T>
): ModalRef<R> {
const id = crypto.randomUUID();
// Create wrapper component
const wrapperRef = this.viewContainerRef.createComponent(ModalWrapperComponent);
wrapperRef.setInput('config', config);
// Create content component inside wrapper
const contentRef = wrapperRef.instance.createContent(component);
// Set inputs on content component
if (config?.data) {
Object.entries(config.data).forEach(([key, value]) => {
contentRef.setInput(key, value);
});
}
this.activeModals.set(id, wrapperRef);
const modalRef = new ModalRef<R>(id, () => this.close(id));
// Handle close events
wrapperRef.instance.closed.subscribe((result) => {
modalRef.setResult(result);
this.close(id);
});
return modalRef;
}
close(id: string) {
const ref = this.activeModals.get(id);
if (ref) {
ref.destroy();
this.activeModals.delete(id);
}
}
closeAll() {
this.activeModals.forEach((ref, id) => this.close(id));
}
}
// modal-ref.ts
export class ModalRef<R = any> {
private resultSubject = new Subject<R | undefined>();
readonly result$ = this.resultSubject.asObservable();
constructor(
readonly id: string,
private closeFn: () => void
) {}
close(result?: R) {
this.setResult(result);
this.closeFn();
}
setResult(result?: R) {
this.resultSubject.next(result);
this.resultSubject.complete();
}
}
// modal-wrapper.component.ts
@Component({
selector: 'app-modal-wrapper',
standalone: true,
template: `
<div class="modal-backdrop" (click)="onBackdropClick()">
<div
class="modal-content"
[class]="config()?.panelClass"
(click)="$event.stopPropagation()"
>
@if (config()?.showHeader !== false) {
<header class="modal-header">
<h2>{{ config()?.title }}</h2>
<button (click)="close()">×</button>
</header>
}
<div class="modal-body">
<ng-container #contentContainer />
</div>
</div>
</div>
`,
animations: [
trigger('fadeIn', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms', style({ opacity: 1 }))
])
])
]
})
export class ModalWrapperComponent {
@ViewChild('contentContainer', { read: ViewContainerRef })
contentContainer!: ViewContainerRef;
config = input<ModalConfig<any>>();
closed = output<any>();
createContent<T>(component: Type<T>): ComponentRef<T> {
return this.contentContainer.createComponent(component);
}
close(result?: any) {
this.closed.emit(result);
}
onBackdropClick() {
if (this.config()?.disableClose !== true) {
this.close();
}
}
}
// Usage
@Component({
template: `
<button (click)="openConfirmDialog()">Delete Item</button>
`
})
export class ItemActionsComponent {
private modal = inject(ModalService);
async openConfirmDialog() {
const modalRef = this.modal.open(ConfirmDialogComponent, {
title: 'Confirm Delete',
data: {
message: 'Are you sure you want to delete this item?',
confirmText: 'Delete',
cancelText: 'Cancel'
}
});
const confirmed = await firstValueFrom(modalRef.result$);
if (confirmed) {
this.deleteItem();
}
}
}
Component Factory with Inputs
Copy
// widget-factory.service.ts
@Injectable({ providedIn: 'root' })
export class WidgetFactory {
private registry = new Map<string, Type<any>>();
register(type: string, component: Type<any>) {
this.registry.set(type, component);
}
create<T>(
type: string,
container: ViewContainerRef,
inputs?: Partial<T>
): ComponentRef<T> | null {
const component = this.registry.get(type);
if (!component) {
console.warn(`Widget type "${type}" not registered`);
return null;
}
const ref = container.createComponent(component);
if (inputs) {
Object.entries(inputs).forEach(([key, value]) => {
ref.setInput(key, value);
});
}
return ref;
}
}
// dashboard.component.ts
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<div class="dashboard-grid">
@for (widget of widgets(); track widget.id) {
<div class="widget-cell" [style.grid-area]="widget.position">
<ng-container #widgetHost />
</div>
}
</div>
`
})
export class DashboardComponent implements AfterViewInit {
@ViewChildren('widgetHost', { read: ViewContainerRef })
widgetHosts!: QueryList<ViewContainerRef>;
private factory = inject(WidgetFactory);
widgets = input.required<WidgetConfig[]>();
ngAfterViewInit() {
this.renderWidgets();
// Re-render when widgets change
effect(() => {
this.widgets();
this.renderWidgets();
});
}
private renderWidgets() {
const hosts = this.widgetHosts.toArray();
this.widgets().forEach((widget, index) => {
const host = hosts[index];
if (host) {
host.clear();
this.factory.create(widget.type, host, widget.config);
}
});
}
}
interface WidgetConfig {
id: string;
type: string;
position: string;
config: Record<string, any>;
}
Portal Pattern (CDK)
Copy
import { PortalModule, CdkPortal, DomPortalOutlet } from '@angular/cdk/portal';
// Using CdkPortal
@Component({
selector: 'app-tooltip',
standalone: true,
imports: [PortalModule],
template: `
<ng-template cdkPortal #tooltipPortal>
<div class="tooltip" [style.top.px]="y()" [style.left.px]="x()">
<ng-content />
</div>
</ng-template>
`
})
export class TooltipComponent implements OnInit, OnDestroy {
@ViewChild(CdkPortal) portal!: CdkPortal;
x = input(0);
y = input(0);
private outlet!: DomPortalOutlet;
constructor(
private appRef: ApplicationRef,
private injector: Injector
) {}
ngOnInit() {
// Create outlet at document body
this.outlet = new DomPortalOutlet(
document.body,
null,
this.appRef,
this.injector
);
}
show() {
this.outlet.attach(this.portal);
}
hide() {
this.outlet.detach();
}
ngOnDestroy() {
this.outlet.dispose();
}
}
// Overlay Service using CDK Overlay
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
@Injectable({ providedIn: 'root' })
export class OverlayService {
private overlay = inject(Overlay);
openDropdown<T>(
trigger: ElementRef,
component: Type<T>,
inputs?: Record<string, any>
): OverlayRef {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(trigger)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top'
}
]);
const overlayRef = this.overlay.create({
positionStrategy,
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop'
});
const portal = new ComponentPortal(component);
const componentRef = overlayRef.attach(portal);
if (inputs) {
Object.entries(inputs).forEach(([key, value]) => {
componentRef.setInput(key, value);
});
}
overlayRef.backdropClick().subscribe(() => overlayRef.dispose());
return overlayRef;
}
}
Form Builder Pattern
Copy
// dynamic-form.component.ts
@Component({
selector: 'app-dynamic-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form()" (ngSubmit)="onSubmit()">
@for (field of fields(); track field.key) {
<div class="form-field">
<label [for]="field.key">{{ field.label }}</label>
<ng-container #fieldHost />
</div>
}
<button type="submit" [disabled]="form().invalid">Submit</button>
</form>
`
})
export class DynamicFormComponent implements AfterViewInit {
@ViewChildren('fieldHost', { read: ViewContainerRef })
fieldHosts!: QueryList<ViewContainerRef>;
fields = input.required<FormFieldConfig[]>();
form = input.required<FormGroup>();
submitted = output<Record<string, any>>();
private componentMap: Record<string, Type<any>> = {
text: TextInputComponent,
number: NumberInputComponent,
select: SelectInputComponent,
checkbox: CheckboxInputComponent,
date: DateInputComponent,
textarea: TextareaInputComponent
};
ngAfterViewInit() {
this.renderFields();
}
private renderFields() {
const hosts = this.fieldHosts.toArray();
this.fields().forEach((field, index) => {
const host = hosts[index];
const component = this.componentMap[field.type];
if (host && component) {
host.clear();
const ref = host.createComponent(component);
ref.setInput('field', field);
ref.setInput('control', this.form().get(field.key));
}
});
}
onSubmit() {
if (this.form().valid) {
this.submitted.emit(this.form().value);
}
}
}
interface FormFieldConfig {
key: string;
type: 'text' | 'number' | 'select' | 'checkbox' | 'date' | 'textarea';
label: string;
placeholder?: string;
options?: { value: any; label: string }[];
validators?: ValidatorFn[];
}
// text-input.component.ts
@Component({
selector: 'app-text-input',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<input
type="text"
[id]="field().key"
[formControl]="control()"
[placeholder]="field().placeholder ?? ''"
/>
@if (control().invalid && control().touched) {
<span class="error">{{ getErrorMessage() }}</span>
}
`
})
export class TextInputComponent {
field = input.required<FormFieldConfig>();
control = input.required<FormControl>();
getErrorMessage(): string {
const errors = this.control().errors;
if (errors?.['required']) return 'This field is required';
if (errors?.['minlength']) return `Minimum ${errors['minlength'].requiredLength} characters`;
return 'Invalid value';
}
}
Best Practices
Clean Up References
Always destroy component refs to prevent memory leaks
Use setInput()
Prefer setInput() over direct instance access for signals support
Lazy Load
Dynamically import components to reduce bundle size
Type Safety
Use generics to maintain type safety with dynamic components
Next: Custom Schematics
Learn to create custom Angular CLI schematics