π§© 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-contentto 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.
β 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 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.
| Hook | When it runs | Use it for |
|---|---|---|
constructor | Before any hooks | Dependency injection only β no heavy work |
ngOnChanges | When an @Input() changes | Reacting to or validating new input values |
ngOnInit | Once, after the first ngOnChanges | Fetching data, setting up subscriptions |
ngAfterViewInit | After the view and child views render | DOM access, initializing third-party UI libs |
ngOnDestroy | Just before the component is removed | Unsubscribing, 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.
@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) | |
|---|---|---|
| Concern | How things work | How things look |
| Data | Fetches from services, holds state | Receives via @Input |
| Events | Handles actions | Emits via @Output |
| Reuse | Tied to a feature | Reusable 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
@Inputreference 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:
| Mode | Behavior |
|---|---|
Emulated (default) | Scopes styles by adding attributes β no native Shadow DOM needed |
ShadowDom | Uses the browser's native Shadow DOM for true isolation |
None | No 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:
- Generate a presentational
UserCardComponentthat takes auserinput and emits aselectevent when clicked. - Generate a smart
UserListComponentthat holds an array of users and renders aUserCardComponentfor each with@for. - When a card emits
select, have the list log the chosen user's name. - Add a generic
PanelComponentthat usesng-contentwith 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 inngOnDestroy. - Data flows down via
@Inputand events flow up via@Output;@ViewChildis for imperative access. ng-contentprojects parent markup into reusable wrappers.- The smart/presentational split plus OnPush and style encapsulation keep apps fast and maintainable.
π Further Reading
- angular.dev β Components guide
- Lifecycle hooks reference
- Inputs and Outputs
- Content projection with ng-content
π 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.