Component Communication in Angular¶
Component communication is a fundamental concept in Angular applications. Components need to share data and coordinate their behavior to create cohesive user interfaces. Angular provides several mechanisms for components to communicate with each other, enabling you to build complex, interconnected applications.
Parent-Child Communication with @Input¶
The @Input decorator allows a parent component to pass data to its child components. This is the primary way to send data down the component tree.
Basic @Input Usage¶
// child.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-child',
template: `
<div class="child-component">
<h3>{{ title }}</h3>
<p>User: {{ user?.name }}</p>
<p>Age: {{ user?.age }}</p>
</div>
`
})
export class ChildComponent {
@Input() title: string = '';
@Input() user?: { name: string; age: number };
}
// parent.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-parent',
template: `
<div class="parent-component">
<h2>Parent Component</h2>
<app-child
[title]="childTitle"
[user]="selectedUser">
</app-child>
</div>
`
})
export class ParentComponent {
childTitle = 'User Details';
selectedUser = { name: 'John Doe', age: 30 };
}
Input with Getters and Setters¶
You can use getters and setters to perform logic when input values change:
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-product',
template: `
<div class="product">
<h3>{{ productName }}</h3>
<p class="price" [class.discounted]="hasDiscount">
{{ formattedPrice }}
</p>
</div>
`
})
export class ProductComponent {
@Input() productName: string = '';
private _price: number = 0;
private _discount: number = 0;
hasDiscount = false;
@Input()
set price(value: number) {
this._price = value;
this.updateFormattedPrice();
}
get price(): number {
return this._price;
}
@Input()
set discount(value: number) {
this._discount = value;
this.hasDiscount = value > 0;
this.updateFormattedPrice();
}
get discount(): number {
return this._discount;
}
formattedPrice: string = '';
private updateFormattedPrice(): void {
const finalPrice = this._price * (1 - this._discount / 100);
this.formattedPrice = `$${finalPrice.toFixed(2)}`;
}
}
Input Validation and Transformation¶
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-rating',
template: `
<div class="rating">
<span *ngFor="let star of stars"
[class.filled]="star <= normalizedRating">
★
</span>
<span class="rating-text">({{ normalizedRating }}/5)</span>
</div>
`
})
export class RatingComponent {
stars = [1, 2, 3, 4, 5];
normalizedRating = 0;
@Input()
set rating(value: number | string) {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
this.normalizedRating = Math.max(0, Math.min(5, numValue || 0));
}
}
Child-Parent Communication with @Output¶
The @Output decorator allows child components to emit events to their parent components using EventEmitter.
Basic @Output Usage¶
// child.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div class="counter">
<button (click)="decrement()">-</button>
<span class="count">{{ count }}</span>
<button (click)="increment()">+</button>
<button (click)="reset()" class="reset">Reset</button>
</div>
`
})
export class CounterComponent {
@Input() count: number = 0;
@Output() countChange = new EventEmitter<number>();
@Output() countReset = new EventEmitter<void>();
increment(): void {
this.count++;
this.countChange.emit(this.count);
}
decrement(): void {
this.count--;
this.countChange.emit(this.count);
}
reset(): void {
this.count = 0;
this.countReset.emit();
}
}
// parent.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-parent',
template: `
<div class="parent">
<h2>Counter Demo</h2>
<p>Current count: {{ currentCount }}</p>
<p>Total changes: {{ totalChanges }}</p>
<app-counter
[count]="currentCount"
(countChange)="onCountChange($event)"
(countReset)="onCountReset()">
</app-counter>
</div>
`
})
export class ParentComponent {
currentCount = 0;
totalChanges = 0;
onCountChange(newCount: number): void {
this.currentCount = newCount;
this.totalChanges++;
console.log(`Count changed to: ${newCount}`);
}
onCountReset(): void {
this.currentCount = 0;
this.totalChanges++;
console.log('Counter was reset');
}
}
Complex Event Data¶
// todo-item.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
interface TodoEvent {
action: 'toggle' | 'delete' | 'edit';
item: TodoItem;
}
@Component({
selector: 'app-todo-item',
template: `
<div class="todo-item" [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleComplete()">
<span class="todo-text" (click)="editItem()">
{{ todo.text }}
</span>
<button (click)="deleteItem()" class="delete-btn">
Delete
</button>
</div>
`
})
export class TodoItemComponent {
@Input() todo!: TodoItem;
@Output() todoAction = new EventEmitter<TodoEvent>();
toggleComplete(): void {
this.todoAction.emit({
action: 'toggle',
item: { ...this.todo, completed: !this.todo.completed }
});
}
editItem(): void {
this.todoAction.emit({
action: 'edit',
item: this.todo
});
}
deleteItem(): void {
this.todoAction.emit({
action: 'delete',
item: this.todo
});
}
}
ViewChild and ViewChildren¶
@ViewChild and @ViewChildren provide direct access to child components, allowing for more complex interactions.
ViewChild for Single Child¶
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { CounterComponent } from './counter.component';
@Component({
selector: 'app-parent',
template: `
<div class="parent">
<button (click)="resetCounter()">Reset Counter</button>
<button (click)="setCounterValue()">Set to 10</button>
<app-counter #counter></app-counter>
<p>Counter value: {{ getCounterValue() }}</p>
</div>
`
})
export class ParentComponent implements AfterViewInit {
@ViewChild('counter') counterComponent!: CounterComponent;
ngAfterViewInit(): void {
// ViewChild is available after view initialization
console.log('Counter component:', this.counterComponent);
}
resetCounter(): void {
this.counterComponent.reset();
}
setCounterValue(): void {
this.counterComponent.count = 10;
}
getCounterValue(): number {
return this.counterComponent?.count || 0;
}
}
ViewChildren for Multiple Children¶
import { Component, ViewChildren, QueryList, AfterViewInit } from '@angular/core';
import { TodoItemComponent } from './todo-item.component';
@Component({
selector: 'app-todo-list',
template: `
<div class="todo-list">
<button (click)="markAllComplete()">Mark All Complete</button>
<button (click)="deleteCompleted()">Delete Completed</button>
<app-todo-item
*ngFor="let todo of todos; trackBy: trackByTodo"
[todo]="todo"
(todoAction)="handleTodoAction($event)">
</app-todo-item>
</div>
`
})
export class TodoListComponent implements AfterViewInit {
@ViewChildren(TodoItemComponent) todoItems!: QueryList<TodoItemComponent>;
todos: TodoItem[] = [
{ id: 1, text: 'Learn Angular', completed: false },
{ id: 2, text: 'Build an app', completed: false },
{ id: 3, text: 'Deploy to production', completed: false }
];
ngAfterViewInit(): void {
console.log('Todo items:', this.todoItems.toArray());
}
markAllComplete(): void {
this.todoItems.forEach(item => {
if (!item.todo.completed) {
item.toggleComplete();
}
});
}
deleteCompleted(): void {
const completedItems = this.todoItems.filter(item => item.todo.completed);
completedItems.forEach(item => item.deleteItem());
}
trackByTodo(index: number, todo: TodoItem): number {
return todo.id;
}
handleTodoAction(event: TodoEvent): void {
switch (event.action) {
case 'toggle':
this.updateTodo(event.item);
break;
case 'delete':
this.deleteTodo(event.item.id);
break;
case 'edit':
this.editTodo(event.item);
break;
}
}
private updateTodo(updatedTodo: TodoItem): void {
const index = this.todos.findIndex(t => t.id === updatedTodo.id);
if (index !== -1) {
this.todos[index] = updatedTodo;
}
}
private deleteTodo(id: number): void {
this.todos = this.todos.filter(t => t.id !== id);
}
private editTodo(todo: TodoItem): void {
// Implement edit logic
console.log('Editing todo:', todo);
}
}
Service-Based Communication¶
Services provide a way for components to communicate across the component tree, especially useful for siblings or distantly related components.
Shared Data Service¶
// data.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({
providedIn: 'root'
})
export class DataService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
private usersSubject = new BehaviorSubject<User[]>([]);
// Observables for components to subscribe to
currentUser$ = this.currentUserSubject.asObservable();
users$ = this.usersSubject.asObservable();
getCurrentUser(): User | null {
return this.currentUserSubject.value;
}
setCurrentUser(user: User | null): void {
this.currentUserSubject.next(user);
}
getUsers(): User[] {
return this.usersSubject.value;
}
addUser(user: User): void {
const currentUsers = this.usersSubject.value;
this.usersSubject.next([...currentUsers, user]);
}
updateUser(updatedUser: User): void {
const currentUsers = this.usersSubject.value;
const index = currentUsers.findIndex(u => u.id === updatedUser.id);
if (index !== -1) {
const newUsers = [...currentUsers];
newUsers[index] = updatedUser;
this.usersSubject.next(newUsers);
}
}
deleteUser(userId: number): void {
const currentUsers = this.usersSubject.value;
this.usersSubject.next(currentUsers.filter(u => u.id !== userId));
}
}
// user-list.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { DataService, User } from './data.service';
@Component({
selector: 'app-user-list',
template: `
<div class="user-list">
<h3>Users</h3>
<ul>
<li *ngFor="let user of users"
(click)="selectUser(user)"
[class.selected]="user.id === currentUser?.id">
{{ user.name }} ({{ user.email }})
</li>
</ul>
</div>
`
})
export class UserListComponent implements OnInit, OnDestroy {
users: User[] = [];
currentUser: User | null = null;
private subscriptions = new Subscription();
constructor(private dataService: DataService) {}
ngOnInit(): void {
this.subscriptions.add(
this.dataService.users$.subscribe(users => {
this.users = users;
})
);
this.subscriptions.add(
this.dataService.currentUser$.subscribe(user => {
this.currentUser = user;
})
);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
selectUser(user: User): void {
this.dataService.setCurrentUser(user);
}
}
Event Bus Service¶
// event-bus.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
export interface AppEvent {
type: string;
payload?: any;
}
@Injectable({
providedIn: 'root'
})
export class EventBusService {
private eventSubject = new Subject<AppEvent>();
emit(eventType: string, payload?: any): void {
this.eventSubject.next({ type: eventType, payload });
}
on(eventType: string): Observable<any> {
return this.eventSubject.pipe(
filter(event => event.type === eventType),
map(event => event.payload)
);
}
onAny(): Observable<AppEvent> {
return this.eventSubject.asObservable();
}
}
// notification.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { EventBusService } from './event-bus.service';
@Component({
selector: 'app-notification',
template: `
<div class="notifications">
<div *ngFor="let notification of notifications"
class="notification"
[class]="notification.type">
{{ notification.message }}
<button (click)="removeNotification(notification.id)">×</button>
</div>
</div>
`
})
export class NotificationComponent implements OnInit, OnDestroy {
notifications: Array<{id: number; message: string; type: string}> = [];
private subscription!: Subscription;
private notificationId = 0;
constructor(private eventBus: EventBusService) {}
ngOnInit(): void {
this.subscription = this.eventBus.on('notification').subscribe(
(data: {message: string; type: string}) => {
this.addNotification(data.message, data.type);
}
);
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
private addNotification(message: string, type: string): void {
const id = ++this.notificationId;
this.notifications.push({ id, message, type });
// Auto-remove after 5 seconds
setTimeout(() => {
this.removeNotification(id);
}, 5000);
}
removeNotification(id: number): void {
this.notifications = this.notifications.filter(n => n.id !== id);
}
}
Component Lifecycle in Communication¶
Understanding how lifecycle hooks interact with component communication is crucial for proper data flow.
OnInit and OnChanges with Input¶
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-user-profile',
template: `
<div class="user-profile" *ngIf="processedUser">
<img [src]="processedUser.avatarUrl" [alt]="processedUser.name">
<h3>{{ processedUser.name }}</h3>
<p>{{ processedUser.description }}</p>
<div class="stats">
<span>Posts: {{ processedUser.postCount }}</span>
<span>Followers: {{ processedUser.followerCount }}</span>
</div>
</div>
`
})
export class UserProfileComponent implements OnInit, OnChanges {
@Input() user?: User;
@Input() showStats = true;
processedUser?: ProcessedUser;
ngOnInit(): void {
console.log('UserProfile initialized');
this.processUser();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['user'] && this.user) {
console.log('User input changed:', changes['user']);
this.processUser();
}
if (changes['showStats']) {
console.log('ShowStats changed:', changes['showStats']);
// Re-process user data based on showStats flag
this.processUser();
}
}
private processUser(): void {
if (!this.user) return;
this.processedUser = {
...this.user,
avatarUrl: this.user.avatarUrl || '/assets/default-avatar.png',
description: this.user.bio || 'No description available',
postCount: this.showStats ? this.user.posts?.length || 0 : 0,
followerCount: this.showStats ? this.user.followers?.length || 0 : 0
};
}
}
interface ProcessedUser extends User {
avatarUrl: string;
description: string;
postCount: number;
followerCount: number;
}
Data Flow Patterns¶
Unidirectional Data Flow¶
// app.component.ts - Root component managing state
import { Component } from '@angular/core';
interface AppState {
currentView: 'list' | 'detail';
selectedProduct?: Product;
products: Product[];
cart: CartItem[];
}
@Component({
selector: 'app-root',
template: `
<div class="app">
<app-header
[cartItemCount]="appState.cart.length"
(viewChange)="handleViewChange($event)">
</app-header>
<app-product-list
*ngIf="appState.currentView === 'list'"
[products]="appState.products"
(productSelect)="handleProductSelect($event)"
(addToCart)="handleAddToCart($event)">
</app-product-list>
<app-product-detail
*ngIf="appState.currentView === 'detail'"
[product]="appState.selectedProduct"
(back)="handleBack()"
(addToCart)="handleAddToCart($event)">
</app-product-detail>
</div>
`
})
export class AppComponent {
appState: AppState = {
currentView: 'list',
products: [],
cart: []
};
handleViewChange(view: 'list' | 'detail'): void {
this.appState = { ...this.appState, currentView: view };
}
handleProductSelect(product: Product): void {
this.appState = {
...this.appState,
currentView: 'detail',
selectedProduct: product
};
}
handleAddToCart(product: Product): void {
const existingItem = this.appState.cart.find(item => item.id === product.id);
if (existingItem) {
const updatedCart = this.appState.cart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
this.appState = { ...this.appState, cart: updatedCart };
} else {
this.appState = {
...this.appState,
cart: [...this.appState.cart, { ...product, quantity: 1 }]
};
}
}
handleBack(): void {
this.appState = {
...this.appState,
currentView: 'list',
selectedProduct: undefined
};
}
}
Smart and Dumb Components Pattern¶
// Smart Component (Container)
@Component({
selector: 'app-todo-container',
template: `
<div class="todo-container">
<app-todo-form (todoAdd)="addTodo($event)"></app-todo-form>
<app-todo-list
[todos]="todos"
[filter]="currentFilter"
(todoToggle)="toggleTodo($event)"
(todoDelete)="deleteTodo($event)"
(filterChange)="changeFilter($event)">
</app-todo-list>
</div>
`
})
export class TodoContainerComponent {
todos: TodoItem[] = [];
currentFilter: 'all' | 'active' | 'completed' = 'all';
addTodo(text: string): void {
const newTodo: TodoItem = {
id: Date.now(),
text,
completed: false
};
this.todos = [...this.todos, newTodo];
}
toggleTodo(id: number): void {
this.todos = this.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
deleteTodo(id: number): void {
this.todos = this.todos.filter(todo => todo.id !== id);
}
changeFilter(filter: 'all' | 'active' | 'completed'): void {
this.currentFilter = filter;
}
}
// Dumb Component (Presentational)
@Component({
selector: 'app-todo-form',
template: `
<form (ngSubmit)="onSubmit()" class="todo-form">
<input
type="text"
[(ngModel)]="todoText"
placeholder="Add a new todo..."
required>
<button type="submit" [disabled]="!todoText.trim()">
Add Todo
</button>
</form>
`
})
export class TodoFormComponent {
@Output() todoAdd = new EventEmitter<string>();
todoText = '';
onSubmit(): void {
if (this.todoText.trim()) {
this.todoAdd.emit(this.todoText.trim());
this.todoText = '';
}
}
}
Best Practices¶
1. Use TypeScript Interfaces for Type Safety¶
// Define clear interfaces for communication
interface ProductEvent {
type: 'select' | 'add-to-cart' | 'remove-from-cart';
product: Product;
quantity?: number;
}
interface CartState {
items: CartItem[];
total: number;
itemCount: number;
}
@Component({
selector: 'app-product',
template: `...`
})
export class ProductComponent {
@Input() product!: Product;
@Output() productEvent = new EventEmitter<ProductEvent>();
selectProduct(): void {
this.productEvent.emit({
type: 'select',
product: this.product
});
}
}
2. Implement Proper Unsubscription¶
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-component',
template: '...'
})
export class ComponentWithSubscriptions implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
this.dataService.data$
.pipe(takeUntil(this.destroy$))
.subscribe(data => {
// Handle data
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
3. Use OnPush Change Detection for Performance¶
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-optimized-component',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OptimizedComponent {
@Input() data!: any[];
// Component will only update when inputs change by reference
// or when manually triggered
}
4. Validate Input Properties¶
@Component({
selector: 'app-validated-component',
template: `...`
})
export class ValidatedComponent implements OnInit {
@Input() requiredData!: string;
@Input() optionalData?: number;
ngOnInit(): void {
if (!this.requiredData) {
throw new Error('requiredData is mandatory for ValidatedComponent');
}
}
}
Common Pitfalls¶
1. Memory Leaks from Unsubscribed Observables¶
// ❌ Bad: Memory leak
@Component({...})
export class BadComponent implements OnInit {
ngOnInit(): void {
this.dataService.data$.subscribe(data => {
// This subscription is never unsubscribed
});
}
}
// ✅ Good: Proper cleanup
@Component({...})
export class GoodComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.dataService.data$
.pipe(takeUntil(this.destroy$))
.subscribe(data => {
// Properly cleaned up
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
2. Mutating Input Objects¶
// ❌ Bad: Mutating input object
@Component({...})
export class BadComponent {
@Input() user!: User;
updateUser(): void {
this.user.name = 'New Name'; // Mutates parent's data
}
}
// ✅ Good: Emitting changes
@Component({...})
export class GoodComponent {
@Input() user!: User;
@Output() userChange = new EventEmitter<User>();
updateUser(): void {
const updatedUser = { ...this.user, name: 'New Name' };
this.userChange.emit(updatedUser);
}
}
3. Overusing ViewChild¶
// ❌ Bad: Overusing ViewChild for simple communication
@Component({
template: `
<app-child #child></app-child>
<button (click)="child.doSomething()">Action</button>
`
})
export class BadParentComponent {}
// ✅ Good: Using proper component communication
@Component({
template: `
<app-child (action)="handleAction()"></app-child>
<button (click)="triggerAction = !triggerAction">Action</button>
`
})
export class GoodParentComponent {
triggerAction = false;
handleAction(): void {
// Handle the action
}
}
Summary¶
Component communication in Angular is essential for building interactive applications. The key patterns include:
- @Input for parent-to-child data flow
- @Output for child-to-parent event communication
- ViewChild/ViewChildren for direct component access
- Services for complex state management and sibling communication
- Lifecycle hooks for proper timing of communication
Remember to:
- Keep data flow unidirectional when possible
- Use TypeScript for type safety
- Properly manage subscriptions to prevent memory leaks
- Follow the smart/dumb component pattern for better architecture
- Validate inputs and handle edge cases
Next, we'll explore Angular directives and how they can enhance your component templates with reusable behavior and DOM manipulation.