En esta página

Inputs, outputs y model signals

12 min lectura TextoCap. 2 — Reactividad

Comunicación entre componentes

Angular ofrece tres primitivas para la comunicación padre-hijo:

  • input() — El padre envia datos al hijo (unidireccional)
  • output() — El hijo emite eventos al padre
  • model() — Binding bidireccional entre padre e hijo

Input signals

Los input() reemplazan al decorador @Input. Son signals de solo lectura:

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

// Input opcional con valor por defecto
readonly título = input('Sin título');

// Input requerido (obligatorio en el template)
readonly userId = input.required<number>();

// Input con transformación
readonly deshabilitado = input(false, {
  transform: (valor: string | boolean) => typeof valor === 'string' ? valor !== 'false' : valor,
});

// Input con alias
readonly datos = input<string[]>([], { alias: 'data' });

Usar inputs en el template del padre

<!-- String literal (sin corchetes) -->
<app-card título="Bienvenido" />

<!-- Binding a expresión (con corchetes) -->
<app-card [título]="tituloSignal()" [userId]="42" />

Output signals

Los output() reemplazan al decorador @Output. Emiten eventos al componente padre:

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

// Output simple
readonly click = output<void>();

// Output con datos
readonly seleccionado = output<number>();

// Output con alias
readonly cerrar = output<void>({ alias: 'close' });

Emitir un evento

// En el método del componente
this.seleccionado.emit(42);
this.click.emit();

Escuchar en el padre

<app-lista (seleccionado)="manejarSeleccion($event)" />

Model signals

model() crea un signal que soporta two-way binding. Internamente genera un input y un output con el sufijo Change:

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

// model() === input valor + output valorChange
readonly valor = model(0);

// model requerido
readonly selección = model.required<string>();

Two-way binding

<!-- El padre puede leer y escribir el model del hijo -->
<app-slider [(valor)]="miValor" />

<!-- Equivalente largo -->
<app-slider [valor]="miValor()" (valorChange)="miValor.set($event)" />

Patron contenedor-presentación

Un patrón comun es separar la lógica (contenedor) de la presentación (UI pura):

// Componente presentacional: solo inputs + outputs
@Component({ selector: 'app-producto-card', /* ... */ })
export class ProductoCard {
  readonly producto = input.required<Producto>();
  readonly agregarAlCarrito = output<number>();
}

// Componente contenedor: maneja estado y lógica
@Component({ selector: 'app-tienda', /* ... */ })
export class Tienda {
  readonly productos = signal<Producto[]>([]);

  agregarProducto(id: number): void {
    // lógica de negocio
  }
}

Resumen de API

API Lectura Escritura Two-way Uso
input() Si No No Recibir datos del padre
output() No Emit No Notificar al padre
model() Si Si Si Binding bidireccional

Práctica

  1. Crea un componente con model(): Implementa un componente RangeSlider con un model() para el valor y usa [(valor)] en el componente padre para lograr two-way binding.
  2. Usa input con transform: Crea un input que reciba un string "true" o "false" y lo transforme automaticamente a booleano usando la opcion transform.
  3. Patron contenedor-presentacion: Separa un componente existente en un componente contenedor (con logica y estado) y un componente presentacional (solo input() y output()).

En la siguiente leccion aprenderemos sobre servicios e inyección de dependencias, la columna vertebral de Angular.

model() vs input() + output()
Usa model() cuando necesites two-way binding ([(valor)]). Es equivalente a tener un input() + un output con nombre valorChange, pero con menos boilerplate.
Inputs con alias
Puedes renombrar un input para el template externo: input({ alias: 'título' }). El nombre en la clase será diferente al nombre en el HTML.
import {
  Component, input, output, model,
  computed, ChangeDetectionStrategy,
} from '@angular/core';

// ---- Componente hijo: Slider ----
@Component({
  selector: 'app-slider',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="slider">
      <label>{{ etiqueta() }}</label>
      <input
        type="range"
        [min]="min()"
        [max]="max()"
        [value]="valor()"
        (input)="valor.set(+$any($event.target).value)"
      />
      <span>{{ valor() }}</span>
    </div>
  `,
})
export class Slider {
  // Input requerido
  readonly etiqueta = input.required<string>();

  // Inputs con valores por defecto
  readonly min = input(0);
  readonly max = input(100);

  // Model: two-way binding automático
  readonly valor = model(50);
}

// ---- Componente padre ----
@Component({
  selector: 'app-editor-color',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [Slider],
  template: `
    <h2>Editor de color</h2>
    <app-slider etiqueta="Rojo"   [max]="255" [(valor)]="rojo" />
    <app-slider etiqueta="Verde"  [max]="255" [(valor)]="verde" />
    <app-slider etiqueta="Azul"   [max]="255" [(valor)]="azul" />

    <div
      class="preview"
      [style.background-color]="colorRGB()"
    >
      {{ colorRGB() }}
    </div>

    <button (click)="copiar.emit(colorRGB())">Copiar color</button>
  `,
})
export class EditorColor {
  readonly rojo = model(128);
  readonly verde = model(128);
  readonly azul = model(64);

  readonly colorRGB = computed(
    () => `rgb(${this.rojo()}, ${this.verde()}, ${this.azul()})`
  );

  readonly copiar = output<string>();
}