Angular Component Architecture

Module 25: Frontend Frameworks & State Management

Components: The Building Blocks of Angular

Components are the fundamental building blocks of Angular applications. They control views (patches of screen) and encapsulate the data, logic, and behavior needed to render them.

Think of components like prefabricated, self-contained sections of a building. Just as prefab sections can be assembled to create a complete structure, components can be combined to build a complete application.

graph TD A[Application] --> B[Header Component] A --> C[Main Content Component] A --> D[Footer Component] C --> E[Navigation Component] C --> F[Product List Component] F --> G[Product Item Component] G --> H[Add to Cart Component] G --> I[Product Rating Component] style A fill:#dd0031 style B fill:#dd0031 style C fill:#dd0031 style D fill:#dd0031 style E fill:#dd0031 style F fill:#dd0031 style G fill:#dd0031 style H fill:#dd0031 style I fill:#dd0031 classDef default stroke:#333,stroke-width:2px;

Benefits of Component-Based Architecture

Anatomy of an Angular Component

Angular components consist of several parts working together:

graph TD A[Component Decorator] --> B[Component Class] B --- C[Properties] B --- D[Methods] B --- E[Lifecycle Hooks] A --> F[Template] A --> G[Styles] style A fill:#dd0031 style B fill:#dd0031 style C fill:#dd0031 style D fill:#dd0031 style E fill:#dd0031 style F fill:#dd0031 style G fill:#dd0031 classDef default stroke:#333,stroke-width:2px;

Component Decorator

The @Component decorator adds metadata to the component class:

@Component({
  selector: 'app-product-list',     // The HTML tag used to insert this component
  templateUrl: './product-list.component.html',  // External template file
  // OR template: `<div>Inline template content</div>`, // Inline template
  styleUrls: ['./product-list.component.css'],   // External styles
  // OR styles: [`h1 { color: blue; }`],         // Inline styles
  providers: [ProductService],       // Component-specific service providers
  encapsulation: ViewEncapsulation.Emulated, // Style encapsulation mode
  changeDetection: ChangeDetectionStrategy.OnPush // Change detection strategy
})

Component Class

The TypeScript class that defines the component's behavior:

export class ProductListComponent implements OnInit, OnDestroy {
  // Properties (state)
  products: Product[] = [];
  selectedProduct: Product | null = null;
  loading = false;
  error: string | null = null;
  private subscription: Subscription | null = null;
  
  // Constructor for dependency injection
  constructor(
    private productService: ProductService, 
    private router: Router,
    private changeDetectorRef: ChangeDetectorRef
  ) { }
  
  // Lifecycle hooks
  ngOnInit(): void {
    this.loadProducts();
  }
  
  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
  
  // Methods (behavior)
  loadProducts(): void {
    this.loading = true;
    this.error = null;
    
    this.subscription = this.productService.getProducts()
      .pipe(
        finalize(() => {
          this.loading = false;
          this.changeDetectorRef.markForCheck();
        })
      )
      .subscribe({
        next: (products) => this.products = products,
        error: (err) => this.error = 'Failed to load products: ' + err.message
      });
  }
  
  selectProduct(product: Product): void {
    this.selectedProduct = product;
  }
  
  viewProductDetails(id: number): void {
    this.router.navigate(['/products', id]);
  }
}

Component Template

The HTML template that defines the component's view:

<!-- product-list.component.html -->
<div class="product-list-container">
  <h2>Products</h2>
  
  <div *ngIf="loading" class="loading-spinner">
    Loading...
  </div>
  
  <div *ngIf="error" class="error-message">
    {{ error }}
    <button (click)="loadProducts()">Try Again</button>
  </div>
  
  <div *ngIf="!loading && !error">
    <div *ngIf="products.length === 0" class="no-products">
      No products found.
    </div>
    
    <div *ngIf="products.length > 0" class="product-grid">
      <div *ngFor="let product of products" 
          class="product-card" 
          [class.selected]="product === selectedProduct"
          (click)="selectProduct(product)">
        
        <img [src]="product.imageUrl" [alt]="product.name">
        <h3>{{ product.name }}</h3>
        <p class="price">{{ product.price | currency }}</p>
        
        <button (click)="viewProductDetails(product.id); $event.stopPropagation()">
          View Details
        </button>
      </div>
    </div>
  </div>
</div>

Component Styles

CSS styles for the component:

/* product-list.component.css */
.product-list-container {
  padding: 20px;
}

.loading-spinner {
  display: flex;
  justify-content: center;
  padding: 20px;
}

.error-message {
  color: red;
  padding: 10px;
  border: 1px solid red;
  border-radius: 4px;
  margin-bottom: 20px;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

.product-card {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 15px;
  transition: transform 0.3s ease, box-shadow 0.3s ease;
  cursor: pointer;
}

.product-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}

.product-card.selected {
  border-color: #dd0031;
  box-shadow: 0 0 0 2px rgba(221, 0, 49, 0.3);
}

.product-card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
}

.product-card h3 {
  margin: 10px 0;
}

.product-card .price {
  font-weight: bold;
  color: #333;
}

Creating Components

There are two main ways to create components in Angular:

Using Angular CLI (Recommended)

// Generate a component
ng generate component products/product-list
// or shorthand
ng g c products/product-list

The CLI generates four files:

And it updates the nearest module to include your component.

Manual Creation

You can also create components manually:

  1. Create the component files (TS, HTML, CSS)
  2. Add the @Component decorator to the class
  3. Export the component class
  4. Register the component in a module's declarations array

Generating Components with Options

// Generate a component without spec (test) file
ng g c products/product-detail --skip-tests

// Generate a component with inline template and styles
ng g c shared/button --inline-template --inline-style
// or shorthand
ng g c shared/button -t -s

// Generate a component with a specific module
ng g c admin/user-list --module=admin

// Generate a component with change detection strategy
ng g c products/product-card --change-detection=OnPush

Component Lifecycle Hooks

Angular components have a lifecycle that you can tap into with hooks:

graph TD A[Component Created] --> B[Constructor] B --> C[ngOnChanges] C --> D[ngOnInit] D --> E[ngDoCheck] E --> F[ngAfterContentInit] F --> G[ngAfterContentChecked] G --> H[ngAfterViewInit] H --> I[ngAfterViewChecked] I --> J{Change?} J -->|Yes| K[ngOnChanges] K --> E J -->|No| L[ngOnDestroy] style A fill:#dd0031 style B fill:#dd0031 style C fill:#dd0031 style D fill:#dd0031 style E fill:#dd0031 style F fill:#dd0031 style G fill:#dd0031 style H fill:#dd0031 style I fill:#dd0031 style J fill:#dd0031 style K fill:#dd0031 style L fill:#dd0031 classDef default stroke:#333,stroke-width:2px;

Key Lifecycle Hooks

import { 
  Component, OnInit, OnDestroy, OnChanges, 
  AfterViewInit, SimpleChanges, Input 
} from '@angular/core';

@Component({
  selector: 'app-lifecycle-demo',
  template: `<p>Value: {{ value }}</p>`
})
export class LifecycleDemoComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
  @Input() value: string = '';
  
  constructor() {
    console.log('Constructor called');
    // Do dependecy injection here
    // DON'T do complex initialization here
  }
  
  ngOnChanges(changes: SimpleChanges): void {
    console.log('ngOnChanges called', changes);
    // Respond to input property changes
  }
  
  ngOnInit(): void {
    console.log('ngOnInit called');
    // Do initialization that requires input properties
    // Fetch initial data
    // Set up subscriptions
  }
  
  ngAfterViewInit(): void {
    console.log('ngAfterViewInit called');
    // Do operations that need the view to be fully initialized
    // Manipulate DOM elements
    // Initialize third-party UI libraries
  }
  
  ngOnDestroy(): void {
    console.log('ngOnDestroy called');
    // Clean up resources
    // Unsubscribe from observables
    // Remove event listeners
    // Prevent memory leaks
  }
}

Common Use Cases for Lifecycle Hooks

Lifecycle Hook Common Use Cases
constructor
  • Dependency injection
  • Simple initialization (no external data)
ngOnInit
  • Data fetching
  • Setting up subscriptions
  • Complex initialization
ngOnChanges
  • Reacting to input property changes
  • Validating input data
  • Deriving values from inputs
ngAfterViewInit
  • Manipulating the DOM
  • Initializing third-party UI libraries
  • Setting up elements requiring rendered view
ngOnDestroy
  • Unsubscribing from observables
  • Clearing timers and intervals
  • Removing event listeners
  • Releasing resources

Component Interaction

Angular components can interact in several ways:

Parent to Child: @Input

Parents pass data to children using input properties:

// child.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-child',
  template: `<p>Hello, {{ name }}!</p>`
})
export class ChildComponent {
  @Input() name: string = '';  // Input property decorated with @Input()
}

// parent.component.html
<app-child [name]="parentName"></app-child>

// parent.component.ts
@Component({
  selector: 'app-parent',
  templateUrl: './parent.component.html'
})
export class ParentComponent {
  parentName = 'John';
}

Child to Parent: @Output and EventEmitter

Children emit events to communicate with parents:

// child.component.ts
import { Component, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-child',
  template: `
    <button (click)="sendMessage()">Send Message to Parent</button>
  `
})
export class ChildComponent {
  @Output() messageEvent = new EventEmitter<string>();
  
  sendMessage() {
    this.messageEvent.emit('Hello from child!');
  }
}

// parent.component.html
<app-child (messageEvent)="receiveMessage($event)"></app-child>
<p>Message from child: {{ message }}</p>

// parent.component.ts
@Component({
  selector: 'app-parent',
  templateUrl: './parent.component.html'
})
export class ParentComponent {
  message = '';
  
  receiveMessage(msg: string) {
    this.message = msg;
  }
}

Two-Way Binding with ngModel

Angular allows for two-way binding with the banana-in-a-box syntax [(ngModel)]:

// custom-input.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-custom-input',
  template: `
    <input 
      [value]="value" 
      (input)="valueChange.emit($event.target.value)"
    >
  `
})
export class CustomInputComponent {
  @Input() value: string = '';
  @Output() valueChange = new EventEmitter<string>();
}

// parent.component.html
<app-custom-input [(value)]="name"></app-custom-input>
<p>Current name: {{ name }}</p>

// This is equivalent to:
<app-custom-input [value]="name" (valueChange)="name = $event"></app-custom-input>

Parent Accessing Child: ViewChild

Parents can access child component instances using @ViewChild:

// timer.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-timer',
  template: `<p>{{ seconds }} seconds have passed</p>`
})
export class TimerComponent {
  seconds = 0;
  
  start() {
    this.seconds = 0;
    setInterval(() => this.seconds++, 1000);
  }
  
  stop() {
    // Code to stop timer
  }
}

// parent.component.ts
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { TimerComponent } from './timer.component';

@Component({
  selector: 'app-parent',
  template: `
    <app-timer></app-timer>
    <button (click)="startTimer()">Start Timer</button>
  `
})
export class ParentComponent implements AfterViewInit {
  @ViewChild(TimerComponent) timerComponent!: TimerComponent;
  
  ngAfterViewInit() {
    // Child component is available after view is initialized
    console.log('Timer component reference:', this.timerComponent);
  }
  
  startTimer() {
    this.timerComponent.start();
  }
}

Child Accessing Parent: Dependency Injection

Children can access parent components through the constructor with dependency injection:

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `
    <app-child></app-child>
  `
})
export class ParentComponent {
  parentMethod() {
    return 'Data from parent';
  }
}

// child.component.ts
@Component({
  selector: 'app-child',
  template: `<p>{{ parentData }}</p>`
})
export class ChildComponent implements OnInit {
  parentData: string = '';
  
  constructor(private parent: ParentComponent) { }
  
  ngOnInit() {
    this.parentData = this.parent.parentMethod();
  }
}
graph TD A[Parent Component] -->|@Input properties| B[Child Component] B -->|@Output events| A A -->|@ViewChild| B B -->|Dependency Injection| A style A fill:#dd0031 style B fill:#dd0031 classDef default stroke:#333,stroke-width:2px;

Component Projection with ng-content

Angular allows you to project content from a parent component into a child component using ng-content:

// card.component.ts
@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div class="card-header">
        <ng-content select="[card-header]"></ng-content>
      </div>
      <div class="card-body">
        <ng-content></ng-content>
      </div>
      <div class="card-footer">
        <ng-content select="[card-footer]"></ng-content>
      </div>
    </div>
  `,
  styles: [`
    .card {
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-bottom: 20px;
    }
    .card-header {
      padding: 10px;
      background-color: #f5f5f5;
      border-bottom: 1px solid #ddd;
    }
    .card-body {
      padding: 15px;
    }
    .card-footer {
      padding: 10px;
      background-color: #f5f5f5;
      border-top: 1px solid #ddd;
    }
  `]
})
export class CardComponent { }

// usage in parent.component.html
<app-card>
  <h2 card-header>Product Details</h2>
  
  <div>
    <p>This is the main content of the card.</p>
    <p>It will be projected into the default ng-content.</p>
  </div>
  
  <div card-footer>
    <button>Add to Cart</button>
    <button>View Details</button>
  </div>
</app-card>

Content projection allows for more flexible and reusable components, similar to the "slots" concept in other frameworks.

Multiple Content Projections

Using the select attribute, you can project content to specific locations in the component:

Conditional Content Projection

You can check if content is projected using @ContentChild:

// card.component.ts
import { Component, ContentChild, ElementRef, AfterContentInit } from '@angular/core';

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div *ngIf="hasHeader" class="card-header">
        <ng-content select="[card-header]"></ng-content>
      </div>
      <div class="card-body">
        <ng-content></ng-content>
      </div>
      <div *ngIf="hasFooter" class="card-footer">
        <ng-content select="[card-footer]"></ng-content>
      </div>
    </div>
  `
})
export class CardComponent implements AfterContentInit {
  @ContentChild('[card-header]') headerContent: ElementRef | undefined;
  @ContentChild('[card-footer]') footerContent: ElementRef | undefined;
  
  hasHeader = false;
  hasFooter = false;
  
  ngAfterContentInit() {
    this.hasHeader = !!this.headerContent;
    this.hasFooter = !!this.footerContent;
  }
}

Smart vs. Presentational Components

A common pattern in Angular applications is to distinguish between two types of components:

Smart Components (Container Components)

Presentational Components (Dumb Components)

graph TD A[ProductListComponent] -->|passes products| B[ProductCardComponent] B -->|emits product selection| A A -->|passes product| C[ProductDetailsComponent] C -->|emits add to cart| A style A fill:#dd0031 style B fill:#f5f5f5 style C fill:#f5f5f5 classDef default stroke:#333,stroke-width:2px;

Example: Smart Component

// product-list.component.ts
@Component({
  selector: 'app-product-list',
  template: `
    <div>
      <h2>Products</h2>
      
      <div *ngIf="loading">Loading...</div>
      <div *ngIf="error">{{ error }}</div>
      
      <div class="products-grid">
        <app-product-card 
          *ngFor="let product of products" 
          [product]="product"
          (select)="onSelectProduct($event)"
          (addToCart)="onAddToCart($event)"
        ></app-product-card>
      </div>
    </div>
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  loading = false;
  error: string | null = null;
  
  constructor(private productService: ProductService, private cartService: CartService) { }
  
  ngOnInit() {
    this.loading = true;
    this.productService.getProducts().subscribe({
      next: (products) => {
        this.products = products;
        this.loading = false;
      },
      error: (err) => {
        this.error = 'Failed to load products';
        this.loading = false;
        console.error(err);
      }
    });
  }
  
  onSelectProduct(product: Product) {
    // Handle product selection
  }
  
  onAddToCart(product: Product) {
    this.cartService.addToCart(product);
  }
}

Example: Presentational Component

// product-card.component.ts
@Component({
  selector: 'app-product-card',
  template: `
    <div class="product-card">
      <img [src]="product.imageUrl" [alt]="product.name">
      <h3>{{ product.name }}</h3>
      <p class="price">{{ product.price | currency }}</p>
      
      <div class="actions">
        <button (click)="onSelect()">View Details</button>
        <button (click)="onAddToCart()">Add to Cart</button>
      </div>
    </div>
  `,
  styleUrls: ['./product-card.component.css']
})
export class ProductCardComponent {
  @Input() product!: Product;
  @Output() select = new EventEmitter<Product>();
  @Output() addToCart = new EventEmitter<Product>();
  
  onSelect() {
    this.select.emit(this.product);
  }
  
  onAddToCart() {
    this.addToCart.emit(this.product);
  }
}

Benefits of This Pattern

Change Detection

Angular's change detection determines when to update the DOM based on data changes:

Default Change Detection

By default, Angular uses a "check everything" approach:

OnPush Change Detection

For better performance, you can use OnPush strategy to update only when:

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  template: `
    <div class="user-profile">
      <h2>{{ user.name }}</h2>
      <p>Email: {{ user.email }}</p>
      <button (click)="updateLastSeen()">Update Last Seen</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent {
  @Input() user!: User;
  
  constructor(private changeDetectorRef: ChangeDetectorRef) {}
  
  updateLastSeen() {
    // This won't update the view unless we explicitly mark it for checking
    // because we're modifying a property of an existing object
    this.user.lastSeen = new Date();
    
    // Force change detection
    this.changeDetectorRef.markForCheck();
  }
}

Immutable Data with OnPush

For the most efficient OnPush usage, use immutable objects:

// In the parent component
updateUser() {
  // Don't modify the existing user object
  // this.user.name = 'New Name'; // This won't trigger OnPush change detection
  
  // Instead, create a new user object
  this.user = { ...this.user, name: 'New Name' };
}

Using the Async Pipe

The async pipe automatically triggers change detection when Observables emit new values:

@Component({
  selector: 'app-user-profile',
  template: `
    <div *ngIf="user$ | async as user" class="user-profile">
      <h2>{{ user.name }}</h2>
      <p>Email: {{ user.email }}</p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent {
  user$: Observable<User>;
  
  constructor(private userService: UserService) {
    this.user$ = this.userService.getCurrentUser();
  }
}

Component Styles and Encapsulation

Angular provides style encapsulation to prevent styles from leaking between components:

Style Encapsulation Modes

import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-styled-component',
  template: `<p class="text">This text is styled</p>`,
  styles: [`
    .text { color: blue; }
  `],
  encapsulation: ViewEncapsulation.Emulated // This is the default
})
export class StyledComponent { }

Style Inheritance

Some styles still inherit regardless of encapsulation:

@Component({
  selector: 'app-styled-parent',
  template: `
    <div class="container">
      <h2>Parent Component</h2>
      <app-styled-child></app-styled-child>
    </div>
  `,
  styles: [`
    /* Styles the host element (app-styled-parent) */
    :host {
      display: block;
      border: 1px solid #ccc;
      padding: 15px;
    }
    
    /* Regular styles, scoped to this component */
    h2 { color: #dd0031; }
    
    /* Forces style to apply to child components too */
    ::ng-deep .important { 
      font-weight: bold;
      color: red;
    }
  `]
})
export class StyledParentComponent { }

Practice Activity

Building a Component Hierarchy

Create a product management system with multiple components:

  1. Set up a new Angular project if you haven't already:
    ng new product-management
    cd product-management
  2. Create the necessary models:
    ng g interface models/product
    ng g interface models/category

    Define the interfaces:

    // models/product.ts
    export interface Product {
      id: number;
      name: string;
      description: string;
      price: number;
      imageUrl: string;
      categoryId: number;
      inStock: boolean;
    }
    
    // models/category.ts
    export interface Category {
      id: number;
      name: string;
    }
  3. Create a data service:
    ng g service services/product

    Implement the service:

    // services/product.service.ts
    import { Injectable } from '@angular/core';
    import { Observable, of } from 'rxjs';
    import { Product } from '../models/product';
    import { Category } from '../models/category';
    
    @Injectable({
      providedIn: 'root'
    })
    export class ProductService {
      private products: Product[] = [
        // Add sample products here
      ];
      
      private categories: Category[] = [
        // Add sample categories here
      ];
      
      getProducts(): Observable<Product[]> {
        return of(this.products);
      }
      
      getCategories(): Observable<Category[]> {
        return of(this.categories);
      }
      
      getProductsByCategory(categoryId: number): Observable<Product[]> {
        return of(this.products.filter(p => p.categoryId === categoryId));
      }
    }
  4. Create components:
    ng g c components/product-list
    ng g c components/product-card --change-detection=OnPush
    ng g c components/product-filter
    ng g c components/category-list
  5. Build the component hierarchy:
    1. The ProductListComponent should be a smart component that fetches products
    2. The ProductCardComponent should be a presentational component that displays a product
    3. The ProductFilterComponent should emit filter events
    4. The CategoryListComponent should display categories and emit selections

Challenge

  1. Add a product detail component with route parameters
  2. Implement OnPush change detection for appropriate components
  3. Add content projection for a reusable card component
  4. Create a shopping cart with the ability to add/remove products

Key Takeaways

Additional Resources