All articles

Angular Nested Components with input() and output()

Parent–child contracts in modern Angular: required signal inputs, typed outputs, why they beat the decorators, and when to switch from prop drilling to a signal service.

0 · log in to like, save & follow Share on LinkedIn Share on X
Angular Nested Components with input() and output()

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.

Angular Nested Components with input() and output()

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 as product(); the compiler enforces that parents supply it (forget the binding and the build fails — with the old @Input() you got undefined at 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; no ngOnChanges bookkeeping.
  • 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() xChange convention, 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.required for 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.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.