All articles

Angular Nested Components with Signal Inputs and Outputs

In Angular 22 a parent passes data to a nested child with signal inputs, input(), and the child reports back with signal outputs, output(). With model(), viewChild() and @for.

0 · log in to like, save & follow Share on LinkedIn Share on X

In Angular 22, a parent component passes data to a nested (child) component through signal inputs, declared with input(), and the child reports back through signal outputs, declared with output(). For a value both sides change, model() gives two-way binding. This article builds a product list (parent) and a product card (child) with all three, and every snippet was compiled against Angular 22.2.

Angular Nested Components with Signal Inputs and Outputs

The @Input() and @Output() decorators that older tutorials use are still supported, but they are the legacy way. Angular's reactivity is now fine-grained: a signal knows exactly which parts of a template read it, so only those parts update. Signal inputs and outputs are how a component joins that system, and they are what you should write in new code.

What changed since the decorator days

If you learned nested components from an older article, these are the differences you will see below:

  • input(), output() and model() replace @Input(), @Output() with EventEmitter, and the input-plus-output pair used for two-way binding.
  • No standalone: true. Components have been standalone by default since Angular 19, so the flag is noise. NgModules are not needed.
  • Shorter names. The current style guide drops the Component suffix: the class is ProductCard in product-card.ts, not ProductCardComponent in product-card.component.ts.
  • readonly and protected. Mark inputs, outputs and queries readonly; mark members used only by the template protected.
  • OnPush is the default change detection strategy from Angular 22. Signals make that painless, because reading a signal in a template is what tells Angular when to re-render.
  • @for and @if replace *ngFor and *ngIf, and inject() replaces constructor injection.

What is a nested component?

A nested component is a component used inside another component's template. The outer one is the parent and the inner one is the child. The parent owns the data and hands a slice of it to each child; the child renders that slice and reports what the user did with it. They talk only through the child's declared inputs and outputs, which keeps both small, testable and reusable.

In our example the parent holds a list of products and renders one <app-product-card> per product.

Pass data down with a signal input

The child declares what it accepts with input(). Each call returns a read-only signal, so you read the value by calling it: product(). Use input.required<T>() when the parent must supply a value; the build fails if the binding is missing, which removes a whole class of "undefined at runtime" bugs.

import { Component, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Product } from './product.model';

@Component({
  selector: 'app-product-card',
  imports: [CurrencyPipe],
  template: `
    <article class="card" [class.featured]="featured()">
      <h3>{{ product().name }}</h3>
      <p>{{ product().price | currency }}</p>
    </article>
  `,
})
export class ProductCard {
  // required: the parent MUST bind [product]
  readonly product = input.required<Product>();
  // optional, with a default value
  readonly featured = input(false);
}

The parent binds with the familiar [property] syntax:

<app-product-card [product]="p" [featured]="true" />

Note the imports: [CurrencyPipe]. A standalone component must import every pipe, directive and component its template uses.

Angular 22 product-card child component using a signal input and a signal output

Send events up with a signal output

To tell the parent about an action, the child declares an output() and calls .emit(). The parent listens with the (event) binding, exactly as it would for a native DOM event.

import { Component, input, output } from '@angular/core';
import { Product } from './product.model';

@Component({
  selector: 'app-product-card',
  template: `
    <h3>{{ product().name }}</h3>
    <button type="button" (click)="addToCart.emit(product())">Add to cart</button>
  `,
})
export class ProductCard {
  readonly product = input.required<Product>();
  readonly addToCart = output<Product>();
}
<app-product-card [product]="p" (addToCart)="addProduct($event)" />

Here $event is typed as Product, the type argument given to output<Product>(). Compared with @Output() addToCart = new EventEmitter<Product>(), an output() is lighter and is not an RxJS subject that code inside the component can misuse. Name outputs for what happened in the domain ("add to cart", "removed"), not for the DOM event behind them, and do not prefix them with "on".

Derive values from inputs with computed()

Because an input is a signal, anything calculated from it should be a computed(). It recalculates only when the input it reads changes, and it replaces the ngOnChanges code that decorator inputs needed.

import { computed } from '@angular/core';

// inside ProductCard
protected readonly isBudget = computed(() => this.product().price < 30);
@if (isBudget()) {
  <small>Budget pick</small>
}

Two-way binding with model()

Sometimes the child both receives a value and changes it; a quantity stepper is the classic case. model() creates a writable signal that supports the [(value)] syntax, so parent and child stay in sync without a separate input and output.

import { Component, model } from '@angular/core';

@Component({
  selector: 'app-qty-stepper',
  template: `
    <button type="button" (click)="decrease()">-</button>
    <span>{{ quantity() }}</span>
    <button type="button" (click)="increase()">+</button>
  `,
})
export class QtyStepper {
  readonly quantity = model(1);

  protected increase() { this.quantity.update(q => q + 1); }
  protected decrease() { this.quantity.update(q => Math.max(1, q - 1)); }
  reset() { this.quantity.set(1); }
}
<app-qty-stepper [(quantity)]="quantity" />

The parent binds a signal of its own (quantity = signal(1)), and Angular keeps the two in step. Use model() only when the child really edits the value; for read-only data a plain input() is clearer.

Transform inputs as they arrive

input() accepts a transform function that runs on every incoming value. Angular ships booleanAttribute and numberAttribute for the common cases, so a bare attribute can act as true, as native HTML attributes do.

import { booleanAttribute, input } from '@angular/core';

// <app-product-card featured> now gives featured() === true
readonly featured = input(false, { transform: booleanAttribute });

Call the child from the parent with viewChild()

Inputs and outputs cover most needs. When the parent has to call a method on a child, use the signal query viewChild() in place of the old @ViewChild decorator.

import { viewChild } from '@angular/core';

// inside the parent
private readonly stepper = viewChild.required(QtyStepper);

protected clearQuantity() {
  this.stepper().reset();
}

Project content with ng-content

Inputs pass data, but sometimes the parent should pass markup. <ng-content> defines a slot in the child that the parent fills; add select for named slots.

<!-- product-card template -->
<article class="card">
  <ng-content select="[badge]" />
  <h3>{{ product().name }}</h3>
  <ng-content />
</article>
<app-product-card [product]="p">
  <span badge>New</span>
  <p>Ships tomorrow.</p>
</app-product-card>

Put it together in the parent

The parent lists its children in imports, renders them with @for, and gets the cart service with inject(). @for requires a track expression and has an @empty block for an empty list.

import { Component, inject, signal, viewChild } from '@angular/core';
import { ProductCard } from './product-card';
import { QtyStepper } from './qty-stepper';
import { CartService } from './cart.service';
import { Product } from './product.model';

@Component({
  selector: 'app-product-list',
  imports: [ProductCard, QtyStepper],
  template: `
    @for (p of products(); track p.id) {
      <app-product-card [product]="p" (addToCart)="addProduct($event)" />
    } @empty {
      <p>No products available.</p>
    }

    <app-qty-stepper [(quantity)]="quantity" />
    <p>Quantity: {{ quantity() }} · Items in cart: {{ cart.count() }}</p>
  `,
})
export class ProductList {
  protected readonly cart = inject(CartService);
  protected readonly quantity = signal(1);
  protected readonly products = signal<Product[]>([
    { id: 1, name: 'Keyboard', price: 49 },
    { id: 2, name: 'Mouse', price: 25 },
  ]);

  protected addProduct(product: Product) {
    this.cart.add(product);
  }
}

cart is protected, not private, because the template reads cart.count(); a private member cannot be used in a template. The service itself uses Angular 22's @Service() decorator, shorthand for @Injectable({ providedIn: 'root' }):

import { Service, computed, signal } from '@angular/core';
import { Product } from './product.model';

@Service()
export class CartService {
  private readonly items = signal<Product[]>([]);
  readonly count = computed(() => this.items().length);

  add(product: Product) {
    this.items.update(items => [...items, product]);
  }
}

Migrating older components

You do not have to rewrite decorator-based components by hand. Angular ships migrations that do it for you:

ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration

The first converts @Input() to input(), the second @Output() to output(), and the third @ViewChild and @ContentChild to signal queries. Run them one at a time, build, and commit after each.

Key takeaways

  • Pass data down with input(); use input.required<T>() when the value is mandatory.
  • Send events up with output<T>() and .emit().
  • Use model() for two-way [(value)] binding when the child edits the value.
  • Derive values from inputs with computed(), not ngOnChanges.
  • Use viewChild() when the parent must call the child directly.
  • Drop standalone: true and the Component suffix; mark inputs and outputs readonly.
  • @Input() and @Output() still work, but they are the legacy API. Migrate with the schematics above.

Frequently asked questions

What replaced @Input() and @Output() in Angular?

Signal inputs and signal outputs: the input() and output() functions. input() returns a read-only signal and input.required<T>() makes a binding mandatory at build time. The decorators still work for existing code, but new code should use the functions.

Are @Input() and @Output() deprecated?

No. They remain fully supported, and decorator-based and signal-based components can be mixed in one application. The Angular team recommends the signal functions for new projects, and the migrations above convert old code.

How do I do two-way binding between parent and child?

Declare the value with model() in the child and bind it in the parent with [(value)]. Angular also exposes a matching valueChange output, which is why the syntax works.

Do I still need NgModules or standalone: true?

No to both. Components are standalone by default, so the parent simply lists its children in the imports array of its @Component metadata.

How is @for different from *ngFor?

@for is built into the template syntax, needs no import, requires a track expression, and offers an @empty block. It replaces the *ngFor directive.

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.