Skip to main content
Dynamic Components

Dynamic Components Overview

Estimated Time: 2 hours | Difficulty: Advanced | Prerequisites: Components, Dependency Injection
Dynamic components are created programmatically at runtime, enabling powerful patterns like modal systems, plugin architectures, and configurable UI builders.
┌─────────────────────────────────────────────────────────────────────────┐
│                 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

// 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

// 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.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

// 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)

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

// 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