Skip to content

17. Advanced Components and Component Architecture

Learning Objectives

By the end of this module, you will be able to:

  • Implement advanced component patterns and architectures
  • Create reusable component libraries
  • Use content projection and multi-slot content projection
  • Implement dynamic components and component factories
  • Design scalable component hierarchies
  • Apply advanced lifecycle management techniques
  • Implement component inheritance and composition patterns

Component Architecture Patterns

Container-Presenter Pattern

The Container-Presenter (Smart-Dumb) pattern separates components into two categories:

// Smart Component (Container)
@Component({
  selector: 'app-user-container',
  template: `
    <app-user-list
      [users]="users$ | async"
      [loading]="loading$ | async"
      (userSelected)="onUserSelected($event)"
      (userDeleted)="onUserDeleted($event)">
    </app-user-list>
  `
})
export class UserContainerComponent {
  users$ = this.userService.getUsers();
  loading$ = this.userService.loading$;

  constructor(private userService: UserService) {}

  onUserSelected(user: User): void {
    this.router.navigate(['/users', user.id]);
  }

  onUserDeleted(userId: number): void {
    this.userService.deleteUser(userId);
  }
}

// Dumb Component (Presenter)
@Component({
  selector: 'app-user-list',
  template: `
    <div class="user-list">
      <div *ngIf="loading" class="loading">Loading...</div>
      <div *ngFor="let user of users" class="user-item">
        <span>{{ user.name }}</span>
        <button (click)="onSelect(user)">View</button>
        <button (click)="onDelete(user.id)">Delete</button>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListComponent {
  @Input() users: User[] = [];
  @Input() loading = false;
  @Output() userSelected = new EventEmitter<User>();
  @Output() userDeleted = new EventEmitter<number>();

  onSelect(user: User): void {
    this.userSelected.emit(user);
  }

  onDelete(userId: number): void {
    this.userDeleted.emit(userId);
  }
}

Component Composition Pattern

// Base Modal Component
@Component({
  selector: 'app-modal',
  template: `
    <div class="modal-overlay" *ngIf="isOpen" (click)="onOverlayClick()">
      <div class="modal-content" (click)="$event.stopPropagation()">
        <div class="modal-header">
          <ng-content select="[slot=header]"></ng-content>
          <button class="close-btn" (click)="close()">×</button>
        </div>
        <div class="modal-body">
          <ng-content></ng-content>
        </div>
        <div class="modal-footer" *ngIf="hasFooter">
          <ng-content select="[slot=footer]"></ng-content>
        </div>
      </div>
    </div>
  `,
  styleUrls: ['./modal.component.scss']
})
export class ModalComponent implements OnInit {
  @Input() isOpen = false;
  @Input() closeOnOverlayClick = true;
  @Output() closed = new EventEmitter<void>();

  hasFooter = false;

  ngOnInit(): void {
    // Check if footer content is projected
    this.hasFooter = this.hasProjectedContent('footer');
  }

  close(): void {
    this.isOpen = false;
    this.closed.emit();
  }

  onOverlayClick(): void {
    if (this.closeOnOverlayClick) {
      this.close();
    }
  }

  private hasProjectedContent(slot: string): boolean {
    // Implementation to check projected content
    return true; // Simplified
  }
}

// Usage
@Component({
  template: `
    <app-modal [isOpen]="showModal" (closed)="showModal = false">
      <h2 slot="header">Confirm Action</h2>
      <p>Are you sure you want to proceed?</p>
      <div slot="footer">
        <button (click)="confirm()">Confirm</button>
        <button (click)="cancel()">Cancel</button>
      </div>
    </app-modal>
  `
})
export class ConfirmDialogComponent {
  showModal = false;

  confirm(): void {
    // Handle confirmation
    this.showModal = false;
  }

  cancel(): void {
    this.showModal = false;
  }
}

Content Projection and ng-content

Single Slot Projection

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div class="card-content">
        <ng-content></ng-content>
      </div>
    </div>
  `,
  styleUrls: ['./card.component.scss']
})
export class CardComponent {}

// Usage
@Component({
  template: `
    <app-card>
      <h3>Card Title</h3>
      <p>Card content goes here</p>
    </app-card>
  `
})
export class ParentComponent {}

Multi-slot Content Projection

@Component({
  selector: 'app-article',
  template: `
    <article class="article">
      <header class="article-header">
        <ng-content select="[slot=title]"></ng-content>
        <div class="article-meta">
          <ng-content select="[slot=meta]"></ng-content>
        </div>
      </header>

      <div class="article-content">
        <ng-content></ng-content>
      </div>

      <footer class="article-footer">
        <ng-content select="[slot=actions]"></ng-content>
      </footer>
    </article>
  `,
  styleUrls: ['./article.component.scss']
})
export class ArticleComponent {}

// Usage
@Component({
  template: `
    <app-article>
      <h1 slot="title">{{ article.title }}</h1>
      <div slot="meta">
        <span>By {{ article.author }}</span>
        <span>{{ article.publishDate | date }}</span>
      </div>

      <div [innerHTML]="article.content"></div>

      <div slot="actions">
        <button (click)="like()">Like</button>
        <button (click)="share()">Share</button>
      </div>
    </app-article>
  `
})
export class ArticleViewComponent {
  @Input() article!: Article;

  like(): void {
    // Handle like
  }

  share(): void {
    // Handle share
  }
}

Conditional Content Projection

@Component({
  selector: 'app-expandable-panel',
  template: `
    <div class="panel">
      <div class="panel-header" (click)="toggle()">
        <ng-content select="[slot=header]"></ng-content>
        <span class="toggle-icon">{{ isExpanded ? '▼' : '▶' }}</span>
      </div>

      <div class="panel-content" *ngIf="isExpanded" [@slideDown]>
        <ng-content></ng-content>
      </div>

      <div class="panel-footer" *ngIf="isExpanded && hasFooter">
        <ng-content select="[slot=footer]"></ng-content>
      </div>
    </div>
  `,
  animations: [
    trigger('slideDown', [
      transition(':enter', [
        style({ height: '0', opacity: 0 }),
        animate('300ms ease-in', style({ height: '*', opacity: 1 }))
      ]),
      transition(':leave', [
        animate('300ms ease-out', style({ height: '0', opacity: 0 }))
      ])
    ])
  ]
})
export class ExpandablePanelComponent implements AfterContentInit {
  @Input() isExpanded = false;
  @ContentChild('footer') footerContent!: ElementRef;

  hasFooter = false;

  ngAfterContentInit(): void {
    this.hasFooter = !!this.footerContent;
  }

  toggle(): void {
    this.isExpanded = !this.isExpanded;
  }
}

Dynamic Components

Component Factory and ViewContainerRef

@Injectable({
  providedIn: 'root'
})
export class DynamicComponentService {
  constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector
  ) {}

  createComponent<T>(
    component: Type<T>,
    viewContainerRef: ViewContainerRef,
    inputs?: Partial<T>
  ): ComponentRef<T> {
    const factory = this.componentFactoryResolver.resolveComponentFactory(component);
    const componentRef = viewContainerRef.createComponent(factory);

    if (inputs) {
      Object.assign(componentRef.instance, inputs);
    }

    return componentRef;
  }
}

// Host Component
@Component({
  selector: 'app-dynamic-host',
  template: `
    <div class="dynamic-container">
      <button (click)="loadComponent('alert')">Load Alert</button>
      <button (click)="loadComponent('notification')">Load Notification</button>
      <button (click)="clearComponents()">Clear</button>

      <ng-template #dynamicHost></ng-template>
    </div>
  `
})
export class DynamicHostComponent {
  @ViewChild('dynamicHost', { read: ViewContainerRef, static: true })
  dynamicHost!: ViewContainerRef;

  private componentRefs: ComponentRef<any>[] = [];

  constructor(private dynamicService: DynamicComponentService) {}

  loadComponent(type: string): void {
    let component: Type<any>;
    let inputs: any = {};

    switch (type) {
      case 'alert':
        component = AlertComponent;
        inputs = { message: 'This is an alert!', type: 'warning' };
        break;
      case 'notification':
        component = NotificationComponent;
        inputs = { title: 'Notification', message: 'You have a new message' };
        break;
      default:
        return;
    }

    const componentRef = this.dynamicService.createComponent(
      component,
      this.dynamicHost,
      inputs
    );

    // Subscribe to component events
    if (componentRef.instance.closed) {
      componentRef.instance.closed.subscribe(() => {
        this.removeComponent(componentRef);
      });
    }

    this.componentRefs.push(componentRef);
  }

  clearComponents(): void {
    this.componentRefs.forEach(ref => ref.destroy());
    this.componentRefs = [];
    this.dynamicHost.clear();
  }

  private removeComponent(componentRef: ComponentRef<any>): void {
    const index = this.componentRefs.indexOf(componentRef);
    if (index > -1) {
      this.componentRefs.splice(index, 1);
      componentRef.destroy();
    }
  }
}

Portal Pattern Implementation

// Portal Service
@Injectable({
  providedIn: 'root'
})
export class PortalService {
  private portals = new Map<string, ViewContainerRef>();

  registerPortal(name: string, viewContainer: ViewContainerRef): void {
    this.portals.set(name, viewContainer);
  }

  unregisterPortal(name: string): void {
    this.portals.delete(name);
  }

  renderToPortal<T>(
    portalName: string,
    component: Type<T>,
    inputs?: Partial<T>
  ): ComponentRef<T> | null {
    const portal = this.portals.get(portalName);
    if (!portal) {
      console.warn(`Portal ${portalName} not found`);
      return null;
    }

    const factory = this.componentFactoryResolver.resolveComponentFactory(component);
    const componentRef = portal.createComponent(factory);

    if (inputs) {
      Object.assign(componentRef.instance, inputs);
    }

    return componentRef;
  }

  constructor(private componentFactoryResolver: ComponentFactoryResolver) {}
}

// Portal Outlet Component
@Component({
  selector: 'app-portal-outlet',
  template: '<ng-template #portalHost></ng-template>'
})
export class PortalOutletComponent implements OnInit, OnDestroy {
  @Input() portalName!: string;
  @ViewChild('portalHost', { read: ViewContainerRef, static: true })
  portalHost!: ViewContainerRef;

  constructor(private portalService: PortalService) {}

  ngOnInit(): void {
    if (this.portalName) {
      this.portalService.registerPortal(this.portalName, this.portalHost);
    }
  }

  ngOnDestroy(): void {
    if (this.portalName) {
      this.portalService.unregisterPortal(this.portalName);
    }
  }
}

Component Libraries

Creating a Reusable Component Library

// Button Component
@Component({
  selector: 'lib-button',
  template: `
    <button
      [class]="buttonClasses"
      [disabled]="disabled"
      [type]="type"
      (click)="onClick($event)">
      <lib-icon *ngIf="icon" [name]="icon" [position]="iconPosition"></lib-icon>
      <span class="button-text">
        <ng-content></ng-content>
      </span>
      <lib-spinner *ngIf="loading" size="small"></lib-spinner>
    </button>
  `,
  styleUrls: ['./button.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ButtonComponent {
  @Input() variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'primary';
  @Input() size: 'small' | 'medium' | 'large' = 'medium';
  @Input() disabled = false;
  @Input() loading = false;
  @Input() icon?: string;
  @Input() iconPosition: 'left' | 'right' = 'left';
  @Input() type: 'button' | 'submit' | 'reset' = 'button';
  @Input() fullWidth = false;

  @Output() clicked = new EventEmitter<MouseEvent>();

  get buttonClasses(): string {
    return [
      'lib-button',
      `lib-button--${this.variant}`,
      `lib-button--${this.size}`,
      this.fullWidth ? 'lib-button--full-width' : '',
      this.loading ? 'lib-button--loading' : '',
      this.disabled ? 'lib-button--disabled' : ''
    ].filter(Boolean).join(' ');
  }

  onClick(event: MouseEvent): void {
    if (!this.disabled && !this.loading) {
      this.clicked.emit(event);
    }
  }
}

// Form Field Component
@Component({
  selector: 'lib-form-field',
  template: `
    <div class="form-field" [class.has-error]="hasError">
      <label *ngIf="label" [for]="fieldId" class="form-label">
        {{ label }}
        <span *ngIf="required" class="required-indicator">*</span>
      </label>

      <div class="form-control-wrapper">
        <ng-content></ng-content>
        <lib-icon
          *ngIf="hasError"
          name="error"
          class="error-icon">
        </lib-icon>
      </div>

      <div *ngIf="hint && !hasError" class="form-hint">
        {{ hint }}
      </div>

      <div *ngIf="hasError" class="form-error">
        <ng-container *ngFor="let error of errors">
          {{ error }}
        </ng-container>
      </div>
    </div>
  `,
  styleUrls: ['./form-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => FormFieldComponent),
      multi: true
    }
  ]
})
export class FormFieldComponent implements OnInit, AfterContentInit {
  @Input() label?: string;
  @Input() hint?: string;
  @Input() required = false;
  @Input() errors: string[] = [];

  @ContentChild(NgControl) control?: NgControl;

  fieldId = `field-${Math.random().toString(36).substr(2, 9)}`;

  get hasError(): boolean {
    return this.errors.length > 0 ||
           (this.control?.invalid && this.control?.touched) || false;
  }

  ngOnInit(): void {
    // Component initialization
  }

  ngAfterContentInit(): void {
    if (this.control) {
      // Set up control integration
    }
  }
}

Advanced Lifecycle Management

Custom Lifecycle Hooks

// Custom Lifecycle Interface
export interface OnVisible {
  onVisible(): void;
}

export interface OnHidden {
  onHidden(): void;
}

// Visibility Directive
@Directive({
  selector: '[appVisibility]'
})
export class VisibilityDirective implements OnInit, OnDestroy {
  @Output() visibilityChange = new EventEmitter<boolean>();

  private observer?: IntersectionObserver;

  constructor(private element: ElementRef) {}

  ngOnInit(): void {
    this.setupIntersectionObserver();
  }

  ngOnDestroy(): void {
    if (this.observer) {
      this.observer.disconnect();
    }
  }

  private setupIntersectionObserver(): void {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          const isVisible = entry.isIntersecting;
          this.visibilityChange.emit(isVisible);

          // Call lifecycle methods if they exist
          const component = this.getComponentInstance();
          if (component) {
            if (isVisible && 'onVisible' in component) {
              (component as OnVisible).onVisible();
            } else if (!isVisible && 'onHidden' in component) {
              (component as OnHidden).onHidden();
            }
          }
        });
      },
      { threshold: 0.1 }
    );

    this.observer.observe(this.element.nativeElement);
  }

  private getComponentInstance(): any {
    // Implementation to get component instance
    return null;
  }
}

// Component using custom lifecycle
@Component({
  selector: 'app-lazy-content',
  template: `
    <div appVisibility (visibilityChange)="onVisibilityChange($event)">
      <div *ngIf="isVisible">
        <app-expensive-component></app-expensive-component>
      </div>
    </div>
  `
})
export class LazyContentComponent implements OnVisible, OnHidden {
  isVisible = false;

  onVisible(): void {
    console.log('Component became visible');
    this.loadContent();
  }

  onHidden(): void {
    console.log('Component became hidden');
    this.cleanupResources();
  }

  onVisibilityChange(visible: boolean): void {
    this.isVisible = visible;
  }

  private loadContent(): void {
    // Load heavy content when visible
  }

  private cleanupResources(): void {
    // Cleanup when hidden
  }
}

Performance Optimization Techniques

OnPush Change Detection Strategy

@Component({
  selector: 'app-optimized-list',
  template: `
    <div class="list-container">
      <div
        *ngFor="let item of items; trackBy: trackByFn"
        class="list-item"
        [class.selected]="item.id === selectedId">
        <app-list-item
          [item]="item"
          (selected)="onItemSelected($event)">
        </app-list-item>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OptimizedListComponent {
  @Input() items: Item[] = [];
  @Input() selectedId?: number;
  @Output() itemSelected = new EventEmitter<Item>();

  constructor(private cdr: ChangeDetectorRef) {}

  trackByFn(index: number, item: Item): number {
    return item.id;
  }

  onItemSelected(item: Item): void {
    this.itemSelected.emit(item);
  }

  // Manual change detection trigger when needed
  refreshView(): void {
    this.cdr.markForCheck();
  }
}

Async Pipe Optimization

@Component({
  selector: 'app-async-optimized',
  template: `
    <div *ngIf="data$ | async as data">
      <div class="header">{{ data.title }}</div>
      <div class="content">{{ data.description }}</div>
      <div class="items">
        <div *ngFor="let item of data.items">
          {{ item.name }}
        </div>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AsyncOptimizedComponent {
  data$ = this.dataService.getData().pipe(
    shareReplay(1), // Cache the result
    startWith(null), // Provide initial value
    catchError(error => {
      console.error('Data loading error:', error);
      return of(null);
    })
  );

  constructor(private dataService: DataService) {}
}

Best Practices

Component Design Principles

  1. Single Responsibility: Each component should have one clear purpose
  2. Composition over Inheritance: Prefer component composition
  3. Input/Output Principle: Use @Input for data flow down, @Output for events up
  4. Immutability: Always treat inputs as immutable
  5. Pure Components: Minimize side effects in components

Component Communication Guidelines

// Service for sibling component communication
@Injectable({
  providedIn: 'root'
})
export class ComponentCommunicationService {
  private messageSubject = new Subject<ComponentMessage>();
  public messages$ = this.messageSubject.asObservable();

  sendMessage(message: ComponentMessage): void {
    this.messageSubject.next(message);
  }
}

interface ComponentMessage {
  type: string;
  payload: any;
  sourceComponent: string;
}

// Example usage
@Component({
  selector: 'app-sender',
  template: `<button (click)="sendMessage()">Send Message</button>`
})
export class SenderComponent {
  constructor(private communicationService: ComponentCommunicationService) {}

  sendMessage(): void {
    this.communicationService.sendMessage({
      type: 'USER_SELECTED',
      payload: { userId: 123 },
      sourceComponent: 'SenderComponent'
    });
  }
}

@Component({
  selector: 'app-receiver',
  template: `<div>{{ lastMessage | json }}</div>`
})
export class ReceiverComponent implements OnInit, OnDestroy {
  lastMessage: ComponentMessage | null = null;
  private subscription?: Subscription;

  constructor(private communicationService: ComponentCommunicationService) {}

  ngOnInit(): void {
    this.subscription = this.communicationService.messages$
      .pipe(
        filter(message => message.type === 'USER_SELECTED')
      )
      .subscribe(message => {
        this.lastMessage = message;
      });
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

Practical Exercises

Exercise 1: Build a Reusable Modal System

Create a modal system with the following features:

  • Multiple modal types (confirmation, form, custom)
  • Configurable size and behavior
  • Service-based modal management
  • Animation support

Requirements:

  1. Create a ModalService for programmatic modal control
  2. Implement different modal types using content projection
  3. Add keyboard and overlay interaction handling
  4. Implement modal stacking support

Exercise 2: Dynamic Dashboard Components

Build a dashboard system where components can be dynamically loaded and arranged:

Requirements:

  1. Create a widget registry system
  2. Implement drag-and-drop widget arrangement
  3. Add widget configuration capabilities
  4. Persist dashboard layout

Exercise 3: Advanced Form Component Library

Create a comprehensive form component library:

Requirements:

  1. Multiple input types with consistent styling
  2. Validation integration
  3. Accessibility features
  4. Theme support

Summary

In this module, you learned advanced component patterns and architectural concepts:

  • Component Patterns: Container-Presenter, Composition patterns
  • Content Projection: Single and multi-slot projection with conditional rendering
  • Dynamic Components: Component factories, portals, and runtime component creation
  • Component Libraries: Reusable component design and implementation
  • Performance: OnPush strategy, async optimization, and change detection control
  • Architecture: Communication patterns and best practices

These advanced techniques enable you to build scalable, maintainable, and high-performance Angular applications with sophisticated component architectures.

Next Steps

  • Explore Angular Elements for creating custom elements
  • Learn about Micro Frontends with Angular
  • Study advanced testing patterns for complex components
  • Investigate Angular Universal for server-side rendering
  • Practice with Angular CDK for advanced UI components

Additional Resources