Skip to main content

🧩 Component Architecture

Components are the atoms of every Angular application. In this lesson you'll dissect what a component actually is, trace its lifecycle from birth to destruction, and learn every way components pass data to one another β€” the skills you use in nearly every file you write.

🎯 Learning Objectives

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

  • Break a component into its decorator, class, template, and styles
  • Use the key lifecycle hooks (ngOnInit, ngOnChanges, ngOnDestroy) correctly
  • Wire up parent-child communication with @Input, @Output, and @ViewChild
  • Project content with ng-content to build flexible, reusable components
  • Apply the smart vs presentational pattern and OnPush change detection

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a reusable card component and a smart/presentational product list.

In This Lesson

Why Components?

A component controls a patch of screen and bundles everything that patch needs: the data (state), the logic (behavior), and the markup and styles that render it. Complex interfaces are just trees of these self-contained pieces.

πŸ’‘ A useful analogy: Components are like prefabricated sections of a building. Each section β€” a bathroom pod, a wall panel β€” is manufactured complete and tested on its own, then assembled into the finished structure. You build a whole app the same way: small, verified components combined into pages.
flowchart TD A[AppComponent] --> B[HeaderComponent] A --> C[ProductListComponent] A --> D[FooterComponent] C --> E[ProductCardComponent] E --> F[AddToCartComponent] E --> G[RatingComponent]

βœ… What component thinking buys you

  • Reusability β€” write a card once, use it everywhere (DRY).
  • Maintainability β€” each component has one responsibility.
  • Testability β€” small pieces are tested in isolation.
  • Collaboration β€” teammates work on separate components at once.

Anatomy of a Component

Every component has four parts working together: a decorator supplying metadata, a class holding state and behavior, a template for the view, and styles that stay scoped to it.

The four parts of an Angular component The @Component decorator wraps a class, which owns properties, methods, and lifecycle hooks, and links to a template and styles. @Component decorator Component Class Properties (state) Methods (behavior) Lifecycle hooks Injected services Template HTML + bindings Styles scoped CSS
Figure 1 β€” The decorator ties the class to its template and styles. The class is where your state and logic live.

The decorator

The @Component decorator is metadata that tells Angular how to use the class β€” its selector, template, styles, and change-detection strategy:

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

@Component({
  selector: 'app-product-list',                    // used as <app-product-list>
  standalone: true,
  imports: [CommonModule],                         // template dependencies
  templateUrl: './product-list.component.html',    // or inline `template:`
  styleUrl: './product-list.component.css',        // or inline `styles: [...]`
  changeDetection: ChangeDetectionStrategy.OnPush, // performance strategy
})
export class ProductListComponent { /* ... */ }

The class

The class holds properties (state), methods (behavior), and lifecycle hooks. It uses inject() or the constructor for dependency injection:

import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Subscription, finalize } from 'rxjs';
import { Product } from './product.model';
import { ProductService } from './product.service';

export class ProductListComponent implements OnInit, OnDestroy {
  products: Product[] = [];
  loading = false;
  error: string | null = null;
  private sub: Subscription | null = null;

  private productService = inject(ProductService);
  private router = inject(Router);

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

  ngOnDestroy(): void {
    this.sub?.unsubscribe(); // prevent memory leaks
  }

  loadProducts(): void {
    this.loading = true;
    this.error = null;
    this.sub = this.productService.getProducts()
      .pipe(finalize(() => (this.loading = false)))
      .subscribe({
        next: (products) => (this.products = products),
        error: (err) => (this.error = 'Failed to load: ' + err.message),
      });
  }

  viewDetails(id: number): void {
    this.router.navigate(['/products', id]);
  }
}

The template & styles

The template renders the class's state with Angular's control flow, and the styles stay scoped to this component alone:

<!-- product-list.component.html -->
@if (loading) {
  <p class="spinner">Loading…</p>
} @else if (error) {
  <p class="error">{{ error }} <button (click)="loadProducts()">Retry</button></p>
} @else {
  <div class="grid">
    @for (product of products; track product.id) {
      <article class="card">
        <h3>{{ product.name }}</h3>
        <p>{{ product.price | currency }}</p>
        <button (click)="viewDetails(product.id)">Details</button>
      </article>
    }
  </div>
}

The Lifecycle Hooks

Angular creates, updates, and destroys components on a predictable schedule. Lifecycle hooks are methods Angular calls at each stage so you can run code at exactly the right moment.

flowchart TD A[constructor] --> B[ngOnChanges] B --> C[ngOnInit] C --> D[ngAfterViewInit] D --> E{Input changes?} E -->|Yes| B E -->|Component removed| F[ngOnDestroy]
HookWhen it runsUse it for
constructorBefore any hooksDependency injection only β€” no heavy work
ngOnChangesWhen an @Input() changesReacting to or validating new input values
ngOnInitOnce, after the first ngOnChangesFetching data, setting up subscriptions
ngAfterViewInitAfter the view and child views renderDOM access, initializing third-party UI libs
ngOnDestroyJust before the component is removedUnsubscribing, clearing timers, cleanup

⚠️ The constructor is not for initialization

A common beginner mistake is fetching data in the constructor. At that point the component's inputs aren't set and the view doesn't exist yet. Put data-loading and subscriptions in ngOnInit, and always undo subscriptions in ngOnDestroy to avoid memory leaks.

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

@Component({ selector: 'app-demo', standalone: true, template: `<p>{{ value }}</p>` })
export class DemoComponent implements OnChanges, OnInit, OnDestroy {
  @Input() value = '';

  ngOnChanges(changes: SimpleChanges): void {
    console.log('input changed', changes['value'].currentValue);
  }

  ngOnInit(): void {
    console.log('safe to fetch data and subscribe here');
  }

  ngOnDestroy(): void {
    console.log('clean up subscriptions and timers here');
  }
}

Component Interaction

Components rarely work alone. The golden rule of Angular data flow: data flows down, events flow up. Parents hand data to children through inputs; children notify parents through events.

Parent-child data flow in Angular Data flows from parent to child via @Input; events flow from child to parent via @Output. Parent Child @Input (data) @Output (events)
Figure 2 β€” Data down via @Input, events up via @Output. This one-directional flow keeps state changes easy to trace.

Parent β†’ Child: @Input

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

@Component({
  selector: 'app-greeting',
  standalone: true,
  template: `<p>Hello, {{ name }}!</p>`,
})
export class GreetingComponent {
  @Input() name = '';
}

// in the parent template:
// <app-greeting [name]="userName"></app-greeting>

Child β†’ Parent: @Output

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

@Component({
  selector: 'app-child',
  standalone: true,
  template: `<button (click)="notify()">Send to parent</button>`,
})
export class ChildComponent {
  @Output() messageEvent = new EventEmitter<string>();

  notify(): void {
    this.messageEvent.emit('Hello from the child!');
  }
}

// in the parent template:
// <app-child (messageEvent)="receive($event)"></app-child>

Parent accessing a child: @ViewChild

When a parent needs to call a method on a child instance, @ViewChild gives it a reference β€” available once the view initializes:

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

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [TimerComponent],
  template: `
    <app-timer></app-timer>
    <button (click)="start()">Start Timer</button>
  `,
})
export class ParentComponent implements AfterViewInit {
  @ViewChild(TimerComponent) timer!: TimerComponent;

  ngAfterViewInit(): void {
    // the child reference is ready here, not in ngOnInit
  }

  start(): void {
    this.timer.start();
  }
}

πŸ’‘ Which tool when?

Use @Input/@Output for the vast majority of parent-child communication. Reach for @ViewChild only when you must imperatively call a method on a child. For unrelated components, share a service (covered in the next lesson).

Content Projection

Content projection lets a component accept markup from its parent and drop it into a slot β€” Angular's version of "slots." It's how you build flexible wrappers like cards, dialogs, and panels.

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

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <header><ng-content select="[card-title]"></ng-content></header>
      <div class="body"><ng-content></ng-content></div>
      <footer><ng-content select="[card-actions]"></ng-content></footer>
    </div>
  `,
})
export class CardComponent {}
<!-- using the card in a parent template -->
<app-card>
  <h2 card-title>Product Details</h2>
  <p>This paragraph lands in the default slot.</p>
  <div card-actions>
    <button>Add to Cart</button>
  </div>
</app-card>

The select attribute routes projected content to the right slot. It accepts CSS selectors: [attribute], .class, or element-name. Content without a matching selector falls into the unnamed <ng-content>.

Smart vs Presentational

A widely used pattern splits components into two kinds by responsibility. It keeps logic testable and UI reusable.

Smart (Container)Presentational (Dumb)
ConcernHow things workHow things look
DataFetches from services, holds stateReceives via @Input
EventsHandles actionsEmits via @Output
ReuseTied to a featureReusable anywhere

Smart component

// product-list.component.ts (smart β€” fetches, delegates rendering)
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent],
  template: `
    @for (product of products; track product.id) {
      <app-product-card
        [product]="product"
        (addToCart)="onAddToCart($event)"></app-product-card>
    }
  `,
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  private productService = inject(ProductService);
  private cartService = inject(CartService);

  ngOnInit(): void {
    this.productService.getProducts().subscribe(p => (this.products = p));
  }

  onAddToCart(product: Product): void {
    this.cartService.addToCart(product);
  }
}

Presentational component

// product-card.component.ts (presentational β€” pure display)
@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CommonModule],
  template: `
    <article class="card">
      <h3>{{ product.name }}</h3>
      <p>{{ product.price | currency }}</p>
      <button (click)="addToCart.emit(product)">Add to Cart</button>
    </article>
  `,
})
export class ProductCardComponent {
  @Input() product!: Product;
  @Output() addToCart = new EventEmitter<Product>();
}

βœ… Why split them?

The presentational card knows nothing about services, so you can reuse it in a wishlist, a search page, or a Storybook demo. The smart list owns the data plumbing. Change one without touching the other.

Change Detection & Styles

Two component-level concerns round out the picture: how Angular decides when to update the DOM, and how it keeps your CSS from leaking.

Change detection

By default Angular re-checks every component after any event, timer, or HTTP response. That's predictable but can be wasteful in big apps. The OnPush strategy tells Angular to re-check a component only when one of these happens:

  • An @Input reference changes (a new object, not a mutated one)
  • An event fires from within the component or its children
  • An async-piped Observable emits a new value
  • You explicitly call markForCheck()
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h2>{{ user.name }}</h2>`,
})
export class UserProfileComponent {
  @Input() user!: { name: string };
}

⚠️ OnPush wants immutable data

With OnPush, mutating an object in place (this.user.name = 'New') won't trigger an update because the reference is unchanged. Instead, replace it: this.user = { ...this.user, name: 'New' }. Immutability plus OnPush is a powerful performance combo.

Style encapsulation

Angular scopes a component's styles to that component by default, so class names never collide across the app. Three modes exist:

ModeBehavior
Emulated (default)Scopes styles by adding attributes β€” no native Shadow DOM needed
ShadowDomUses the browser's native Shadow DOM for true isolation
NoneNo scoping β€” styles become global

The special :host selector targets the component's own element, useful for setting display or borders on the wrapper itself.

Hands-on Exercise

πŸ‹οΈ Build a Reusable Card with Smart/Presentational Split

Objective: Practice content projection, @Input/@Output, and the smart/presentational pattern together.

Instructions:

  1. Generate a presentational UserCardComponent that takes a user input and emits a select event when clicked.
  2. Generate a smart UserListComponent that holds an array of users and renders a UserCardComponent for each with @for.
  3. When a card emits select, have the list log the chosen user's name.
  4. Add a generic PanelComponent that uses ng-content with a titled slot, and wrap the user list inside it.
πŸ’‘ Hint

Remember data-down/events-up: the card should never fetch or mutate the list β€” it only displays a user and emits when clicked. The smart list owns the data and reacts to the event. Track your @for loop by user.id.

βœ… Example solution
// user-card.component.ts (presentational)
import { Component, Input, Output, EventEmitter } from '@angular/core';

interface User { id: number; name: string; email: string; }

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <article class="card" (click)="select.emit(user)">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </article>
  `,
})
export class UserCardComponent {
  @Input() user!: User;
  @Output() select = new EventEmitter<User>();
}
// user-list.component.ts (smart)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserCardComponent } from './user-card.component';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [CommonModule, UserCardComponent],
  template: `
    @for (user of users; track user.id) {
      <app-user-card [user]="user" (select)="onSelect($event)"></app-user-card>
    }
  `,
})
export class UserListComponent {
  users = [
    { id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
    { id: 2, name: 'Alan Turing', email: 'alan@example.com' },
  ];

  onSelect(user: { name: string }): void {
    console.log('Selected:', user.name);
  }
}

🎯 Quick Quiz

Question 1: Where should you fetch a component's initial data?

Question 2: How does a child component send data back up to its parent?

Question 3: With OnPush change detection, why does mutating an input object in place fail to update the view?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A component is a decorator + class + template + scoped styles controlling one patch of screen.
  • Lifecycle hooks run on a fixed schedule β€” fetch in ngOnInit, clean up in ngOnDestroy.
  • Data flows down via @Input and events flow up via @Output; @ViewChild is for imperative access.
  • ng-content projects parent markup into reusable wrappers.
  • The smart/presentational split plus OnPush and style encapsulation keep apps fast and maintainable.

πŸ“š Further Reading

πŸš€ What's Next?

Components handle the view, but shared logic and data belong elsewhere. Next you'll learn about services and dependency injection β€” Angular's mechanism for sharing state and behavior across the whole component tree.

πŸŽ‰ Well done!

You can now build and connect components with confidence. On to services.