Real Angular apps are trees: pages contain lists, lists contain cards, cards contain buttons. What keeps a tree maintainable is a strict data contract between parent and child — data flows down through inputs, events flow up through outputs. Angular 20's input() and output() functions are the modern, signal-based version of the @Input()/@Output() decorators. This article builds a parent–child pair with both directions wired, verified with ng build.

The child: declare the contract
A product card that needs a product from above and reports add-to-cart clicks upward:
import { Component, input, output } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
export interface Product { sku: string; name: string; price: number; stock: number; }
@Component({
selector: 'app-product-card',
imports: [CurrencyPipe],
template: `
<div class="card">
<h4>{{ product().name }}</h4>
<p>{{ product().price | currency:'INR' }}</p>
<p>{{ product().stock }} in stock</p>
<button [disabled]="product().stock === 0" (click)="addToCart.emit(product().sku)">
Add to cart
</button>
</div>
`,
})
export class ProductCard {
readonly product = input.required<Product>(); // parent -> child
readonly addToCart = output<string>(); // child -> parent
}
Two lines define the whole interface:
input.required<Product>()— a signal input. The template reads it asproduct(); the compiler enforces that parents supply it (forget the binding and the build fails — with the old@Input()you gotundefinedat runtime instead). Optional inputs take a default:input<string>('none').output<string>()— a typed event emitter.addToCart.emit(sku)sends a string; parents get compile-checked payloads.
The child knows nothing about carts, services, or where products come from. That ignorance is the feature — this card drops into any page.
The parent: bind both directions
@Component({
selector: 'app-root',
imports: [ProductCard],
template: `
@for (p of products; track p.sku) {
<app-product-card [product]="p" (addToCart)="onAddToCart($event)" />
}
<p>Cart: {{ cart().join(', ') || 'empty' }}</p>
`,
})
export class App {
readonly cart = signal<string[]>([]);
readonly products: Product[] = [
{ sku: 'A-100', name: 'Mechanical keyboard', price: 7400, stock: 14 },
{ sku: 'B-200', name: '4K monitor', price: 27400, stock: 6 },
{ sku: 'C-300', name: 'USB-C dock', price: 12400, stock: 0 },
];
onAddToCart(sku: string): void {
this.cart.update(items => [...items, sku]);
}
}
[product]="p" pushes data down; (addToCart)="onAddToCart($event)" catches events, $event being the emitted payload (the SKU). Clicking "Add to cart" on the monitor updates the parent's cart signal, and the summary line re-renders — one-way data flow with an explicit return channel. The sold-out dock's button disables itself from its own input; the parent never micromanages children.
Why signal inputs beat the decorators
Beyond required-ness at compile time:
- They're signals — derive with
computed(() => this.product().price * 1.18)and the derivation updates when the parent passes a new product; nongOnChangesbookkeeping. - Transforms:
input(false, { transform: booleanAttribute })accepts attribute-style usage (<app-card compact />). - Aliasing (
input.required<Product>({ alias: 'item' })) decouples public attribute names from internal ones during refactors. - Two-way when you need it:
model()creates an input/output pair bindable as[(value)]— the modern@Input() x+@Output() xChangeconvention, in one declaration.
Existing codebases full of @Input()/@Output() interoperate seamlessly — parents bind both styles identically — so migrate opportunistically, not big-bang.
When the tree gets deep: stop tunneling
Inputs/outputs are perfect for parent–child. For grandparent-to-grandchild, passing data through an uninvolved middle component ("prop drilling") couples everyone in the path. The Angular answer is a service with signals, injected where needed:
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly items = signal<string[]>([]);
readonly count = computed(() => this.items().length);
add(sku: string) { this.items.update(list => [...list, sku]); }
}
Guideline: two levels of pass-through, switch to a service. Components at any depth inject CartStore; the tree's data contracts stay local while shared state lives in one place. (Content projection — <ng-content> — is the third composition tool, for parents that supply markup rather than data.)
The checklist
- Child owns its template and declares
input()/output(); never reaches for its parent. - Parent owns state; children request changes via events, parent applies them (
cart.update). input.requiredfor must-haves, defaults for options,model()for true two-way.- Deep sharing → signal-holding service, not input tunnels.
The complete pair — plus the lifecycle and directives demos from this series — runs from the companion repository: npm install && ng serve, then click some cards.
Comments (0)
No comments yet — be the first to share your thoughts.