🔗 Component Communication
Real Angular apps are trees of small components, and the whole app only works if those components can talk to one another. This lesson shows you the four communication paths — data down, events up, two-way binding, and shared state — using modern Angular's signal-based input() and output() APIs.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Pass data from a parent to a child using the signal-based
input()function - Send events and data back up to a parent with
output() - Wire up two-way binding with the
model()function and the[( )]banana-in-a-box syntax - React to input changes with
computed(),effect(), andngOnChanges - Choose the right pattern — inputs, outputs, template refs, or a shared service — for a given component relationship
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a parent/child Todo list that passes items down and emits complete/delete events back up.
In This Lesson
Why Components Need to Talk
A modern Angular UI is not one giant page — it is a tree of components. A product page might be a ProductDetail holding a QuantitySelector, a Rating widget, and an AddToCart button. Each is a small, focused piece, but they only add up to a working feature if they can share data and notify each other of changes.
💡 A useful analogy: Think of components as people in an office. Each has a clear job, but the office only functions if they have reliable channels to hand work down, report results back up, and consult a shared filing cabinet. Angular gives you exactly those three channels — inputs (hand down), outputs (report up), and services (the shared cabinet).
The golden rule underneath all of this is one-way data flow: data flows down the tree through inputs, and notifications flow up through outputs. A child never reaches up and mutates its parent directly; it politely emits an event and lets the parent decide what to do. That discipline is what keeps large apps predictable.
The Communication Map
Before the details, here is the whole landscape. The relationship between two components decides which tool you reach for:
input()"] A --> C["Child → Parent
output()"] A --> D["Two-way
model()"] A --> E["Unrelated components
Shared service"] A --> F["Direct access
Template ref / viewChild()"]
📖 Key Terms
Input: a bound property a parent sets on a child, e.g. [product]="product". In modern Angular it is a read-only signal.
Output: an event a child fires that a parent listens for, e.g. (quantityChanged)="...".
Model: a writable signal that combines an input and an output to enable two-way binding.
Parent → Child: Inputs
The most common way a parent shares data is by binding to a child's inputs. Since Angular 17.1 the preferred way to declare one is the input() function, which returns a read-only signal. You read it in the template as product() — with parentheses, because it is a signal.
Child component (product-card.component.ts)
import { Component, input } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
discount: number;
}
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="product-card">
<h3>{{ product().name }}</h3>
<p>Price: {{ product().price | currency }}</p>
@if (showDiscount()) {
<p class="discount">
Discount: {{ product().discount | percent }} —
Final: {{ product().price * (1 - product().discount) | currency }}
</p>
}
</div>
`
})
export class ProductCardComponent {
// Required input — the parent MUST provide it
product = input.required<Product>();
// Optional input with a default value
showDiscount = input(false);
}
Parent component (product-list.component.ts)
import { Component, signal } from '@angular/core';
import { ProductCardComponent } from './product-card.component';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [ProductCardComponent],
template: `
<h2>Product List</h2>
@for (product of products(); track product.id) {
<app-product-card
[product]="product"
[showDiscount]="showSpecialOffers()">
</app-product-card>
}
<button (click)="toggleOffers()">
{{ showSpecialOffers() ? 'Hide' : 'Show' }} Special Offers
</button>
`
})
export class ProductListComponent {
products = signal([
{ id: 1, name: 'Laptop', price: 1299, discount: 0.10 },
{ id: 2, name: 'Phone', price: 799, discount: 0.05 },
{ id: 3, name: 'Headphones', price: 199, discount: 0.15 }
]);
showSpecialOffers = signal(false);
toggleOffers() {
this.showSpecialOffers.update(v => !v);
}
}
The parent binds two inputs on each card: [product]="product" passes the object, and [showDiscount]="showSpecialOffers()" passes a boolean that controls display. Information flows in exactly one direction — downward — like a manager handing a briefing to a team member.
📖 Input options and aliases
The input() function takes an options object for extra control:
// Optional input with a default
size = input<'sm' | 'lg'>('sm');
// Required input — compile error if the parent omits it
product = input.required<Product>();
// Aliased input — parent binds [specialProduct], component reads myProduct()
myProduct = input.required<Product>({ alias: 'specialProduct' });
// Transform the incoming value before it lands in the signal
disabled = input(false, { transform: (v: unknown) => v === '' || !!v });
⚠️ The older decorator still exists
You will see @Input() product!: Product; in older code and tutorials. It still works, but signal inputs are the modern default: they are read-only, integrate with computed(), and never surprise you with an undefined that TypeScript missed. New code should prefer input().
Reacting to Input Changes
Often a child needs to derive something from its inputs, or run logic when they change. With signal inputs you rarely need a lifecycle hook — computed() handles derived values automatically:
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-price-tracker',
standalone: true,
template: `
<div class="tracker">
<h3>{{ stockSymbol() }} Price Tracker</h3>
<p>Current: {{ currentPrice() | currency }}</p>
<p [class.increase]="change() > 0" [class.decrease]="change() < 0">
Change: {{ change() | currency }}
</p>
</div>
`
})
export class PriceTrackerComponent {
stockSymbol = input.required<string>();
currentPrice = input.required<number>();
previousPrice = input(0);
// Recomputes automatically whenever either input signal changes
change = computed(() => this.currentPrice() - this.previousPrice());
}
When you genuinely need a side effect on change — logging, an animation trigger, syncing to localStorage — reach for effect():
import { Component, input, effect } from '@angular/core';
@Component({ /* ... */ })
export class PriceTrackerComponent {
currentPrice = input.required<number>();
constructor() {
effect(() => {
// Runs on init and whenever currentPrice() changes
console.log('Price is now', this.currentPrice());
});
}
}
💡 What about ngOnChanges?
The classic ngOnChanges(changes: SimpleChanges) hook still works and is handy when you need the previous value alongside the current one and you are using the older @Input() decorator. But for new signal-based code, computed() and effect() are cleaner, run less often, and remove a whole class of "I forgot to check firstChange" bugs.
Child → Parent: Outputs
Inputs push data down; outputs send notifications back up. The modern output() function returns an emitter the child calls with .emit(value), and the parent listens with normal event-binding syntax.
Child component (quantity-selector.component.ts)
import { Component, input, output, signal } from '@angular/core';
@Component({
selector: 'app-quantity-selector',
standalone: true,
template: `
<div class="quantity-control">
<button (click)="decrease()" [disabled]="quantity() <= 1">−</button>
<span>{{ quantity() }}</span>
<button (click)="increase()" [disabled]="quantity() >= max()">+</button>
</div>
`
})
export class QuantitySelectorComponent {
quantity = input(1);
max = input(10);
// Declares an event named "quantityChanged" that carries a number
quantityChanged = output<number>();
private current = signal(this.quantity());
increase() {
if (this.current() < this.max()) {
this.current.update(n => n + 1);
this.quantityChanged.emit(this.current());
}
}
decrease() {
if (this.current() > 1) {
this.current.update(n => n - 1);
this.quantityChanged.emit(this.current());
}
}
}
Parent component (product-detail.component.ts)
import { Component, signal, computed } from '@angular/core';
import { QuantitySelectorComponent } from './quantity-selector.component';
@Component({
selector: 'app-product-detail',
standalone: true,
imports: [QuantitySelectorComponent],
template: `
<div class="product-detail">
<h2>{{ product.name }}</h2>
<p>Price: {{ product.price | currency }}</p>
<p>Quantity:</p>
<app-quantity-selector
[quantity]="selectedQuantity()"
[max]="product.stockCount"
(quantityChanged)="onQuantityChanged($event)">
</app-quantity-selector>
<p>Total: {{ totalPrice() | currency }}</p>
<button (click)="addToCart()">Add to Cart</button>
</div>
`
})
export class ProductDetailComponent {
product = {
id: 1, name: 'Wireless Headphones',
price: 249.99, stockCount: 15
};
selectedQuantity = signal(1);
totalPrice = computed(() => this.product.price * this.selectedQuantity());
onQuantityChanged(newQuantity: number) {
this.selectedQuantity.set(newQuantity);
}
addToCart() {
console.log(`Added ${this.selectedQuantity()} × ${this.product.name}`);
}
}
Notice the payload lands in the special $event variable: (quantityChanged)="onQuantityChanged($event)". The child never knows or cares what the parent does with the value — it just announces "the quantity changed to 2." That decoupling is what lets you reuse the selector on any page.
✅ Name outputs after what happened
Good output names describe an event in the past tense from the child's point of view: itemSelected, quantityChanged, deleted. Avoid vague names like change or click that force the reader to guess what actually occurred.
Two-way Binding with model()
Sometimes a value flows both ways: the parent sets it, and the child changes it, and both should stay in sync. That is exactly what [(ngModel)] does on form fields. To build your own two-way-bindable component, use the model() function — it creates a writable signal that is an input and an output at once.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
standalone: true,
template: `
<div class="star-rating">
@for (star of stars; track star) {
<span (click)="rate(star)" [class.filled]="star <= value()">★</span>
}
</div>
`,
styles: [`
.star-rating span { cursor: pointer; font-size: 24px; color: #ccc; }
.star-rating span.filled { color: gold; }
`]
})
export class RatingComponent {
stars = [1, 2, 3, 4, 5];
// A writable, two-way-bindable signal
value = model(0);
rate(newValue: number) {
this.value.set(newValue); // updates locally AND notifies the parent
}
}
The parent now binds it with the "banana-in-a-box" syntax [( )]:
import { Component, signal } from '@angular/core';
import { RatingComponent } from './rating.component';
@Component({
selector: 'app-feedback-form',
standalone: true,
imports: [RatingComponent],
template: `
<h2>Product Feedback</h2>
<app-rating [(value)]="productRating"></app-rating>
<p>You rated this {{ productRating() }} out of 5 stars.</p>
`
})
export class FeedbackFormComponent {
productRating = signal(0);
}
Under the hood [(value)] is pure syntactic sugar for an input [value] plus an output (valueChange). The model() function creates both for you and keeps their names paired automatically. It is like a shared document: whoever edits it, everyone sees the same value.
Template Reference Variables
For simple cases where a parent needs to call a method on a child — start a timer, focus an input — you can grab the child instance directly in the template with a reference variable (a #name):
<app-countdown-timer #timer [seconds]="60"></app-countdown-timer>
<div class="controls">
<button (click)="timer.start()">Start</button>
<button (click)="timer.pause()">Pause</button>
<button (click)="timer.reset()">Reset</button>
</div>
The #timer variable exposes the child's public methods to the parent template. To reach the child from the parent's class (not just the template), use the signal-based viewChild() query:
import { Component, viewChild } from '@angular/core';
import { CountdownTimerComponent } from './countdown-timer.component';
@Component({ /* ... */ })
export class TimerControlComponent {
timer = viewChild.required(CountdownTimerComponent);
startAll() {
this.timer().start();
}
}
⚠️ Use direct access sparingly
Template refs and viewChild() create tight coupling — the parent now knows the child's internal API. Prefer inputs and outputs, which keep components loosely coupled and independently testable. Reach for direct access only for imperative actions (focus, play, scroll) that don't fit the data-down/events-up model.
Hands-on Exercise
🏋️ Build a Todo List with Parent/Child Communication
Objective: Practice all three core channels — inputs down, outputs up, and a form child adding items.
Requirements:
TodoListComponent(parent) owns asignalarray of todos.TodoItemComponent(child) takes a todo viainput()and emitstoggledanddeletedoutputs.- The parent handles those events by updating its signal (mark done / remove).
- Stretch: add a
TodoFormComponentthat emits anaddedoutput with the new todo text.
💡 Hint
Give each todo an id so @for can track it. When toggling, use update() with .map() to return a new array; when deleting, use .filter(). Never mutate the array in place — replace it so the signal notifies.
✅ Sample solution
// todo-item.component.ts
import { Component, input, output } from '@angular/core';
export interface Todo { id: number; text: string; done: boolean; }
@Component({
selector: 'app-todo-item',
standalone: true,
template: `
<li [class.done]="todo().done">
<input type="checkbox" [checked]="todo().done"
(change)="toggled.emit(todo().id)">
<span>{{ todo().text }}</span>
<button (click)="deleted.emit(todo().id)">✕</button>
</li>
`
})
export class TodoItemComponent {
todo = input.required<Todo>();
toggled = output<number>();
deleted = output<number>();
}
// todo-list.component.ts
import { Component, signal } from '@angular/core';
import { TodoItemComponent, Todo } from './todo-item.component';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [TodoItemComponent],
template: `
<h2>My Todos</h2>
<ul>
@for (todo of todos(); track todo.id) {
<app-todo-item
[todo]="todo"
(toggled)="toggle($event)"
(deleted)="remove($event)">
</app-todo-item>
}
</ul>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([
{ id: 1, text: 'Learn signal inputs', done: false },
{ id: 2, text: 'Master outputs', done: false }
]);
toggle(id: number) {
this.todos.update(list =>
list.map(t => t.id === id ? { ...t, done: !t.done } : t));
}
remove(id: number) {
this.todos.update(list => list.filter(t => t.id !== id));
}
}
🎯 Quick Quiz
Question 1: In modern Angular, how does a parent pass data down to a child component?
Question 2: A child needs to notify its parent that a value changed. Which API does it use?
Question 3: What does the model() function give you that a plain input() does not?
Best Practices
✅ Do
- Keep components focused — one clear responsibility each.
- Type your inputs and outputs — use interfaces, not
any. - Name outputs after the event that occurred (
itemSelected,deleted). - Derive with
computed()instead of duplicating input state. - Emit events to change parent state; let the parent own the data.
⚠️ Avoid
- Prop drilling — threading the same input through five layers. If it goes that deep, use a shared service (next lesson).
- Mutating input objects in a child. Emit a change and let the parent update its own copy.
- Tight coupling — a child that reaches up into its parent or assumes a specific parent.
- Over-emitting — firing an output on every keystroke when one meaningful event would do.
Summary & Quiz
🎉 Key Takeaways
- Angular UIs are trees of components that share data via one-way flow: data down, events up.
input()declares data a parent passes down; read it as a signal with().output()lets a child.emit()events back up; the parent listens with($event).model()combines both into a writable signal for two-way[( )]binding.- Derive from inputs with
computed(); run side effects witheffect(). - Template refs and
viewChild()allow direct access — use them sparingly to avoid tight coupling.
📚 Further Reading
- angular.dev — Component inputs
- angular.dev — Custom events with outputs
- angular.dev — Model inputs & two-way binding
🚀 What's Next?
Inputs and outputs are perfect for parent/child pairs, but they get awkward when unrelated components need to share the same data. Next we'll solve that with services and dependency injection — the shared "filing cabinet" any component can reach.
🎉 Nice work!
You can now wire components together in every direction. Let's give them a shared brain with services.