Módulo 06: Comunicación entre componentes

Objetivos del módulo

  • Elegir la vía adecuada para que dos componentes se pasen datos
  • Declarar entradas con input() y input.required(), y derivar valores de ellas con computed
  • Convertir lo que llega por una entrada con transform, booleanAttribute y numberAttribute
  • Entender por qué un objeto recibido por una entrada no se debe mutar
  • Emitir eventos con output() y recogerlos en la plantilla del padre con $event
  • Crear enlaces dobles con model() y saber qué significa exactamente [( )]
  • Llegar a un hijo o a un elemento del DOM con viewChild y viewChildren
  • Recibir HTML del padre con ng-content, varias ranuras, select, contenido por defecto y ngProjectAs
  • Saber cuándo la proyección no basta y hace falta ng-template con ngTemplateOutlet
  • Inspeccionar el contenido proyectado con contentChild y contentChildren
  • Separar una pantalla en un contenedor y varios componentes de presentación
  • Reconocer los errores más habituales de cada una de estas herramientas

1. Cómo se habla un componente con otro

En el módulo 03 viste que una aplicación es un árbol de componentes, y en el módulo 05 aprendiste a guardar estado en señales. Falta unir las dos cosas. Una tabla de empleados no sirve de nada si no recibe los empleados, y un botón “Borrar” no sirve de nada si nadie se entera de que lo han pulsado.

La regla de fondo es sencilla: los datos bajan y los eventos suben. El padre es dueño del estado y se lo entrega al hijo. El hijo no lo cambia por su cuenta: avisa de lo que ha pasado y el padre decide qué hacer. Gracias a esto, cuando un dato de la pantalla está mal, sabes dónde buscar: en el componente que es dueño de ese dato.

Angular ofrece cinco vías, cada una con su propósito:

Cinco flechas entre un componente padre y su hijo: input baja datos, output sube eventos, model comparte un valor en los dos sentidos, viewChild permite al padre llamar al hijo y ng-content baja HTML

Figura 1. Las cinco vías entre padre e hijo. Las cuatro líneas continuas se ven en la plantilla; viewChild actúa desde el código.

Necesitas que… Herramienta Sección
El padre pase un dato al hijo input() 2
El hijo avise al padre de que ha pasado algo output() 5
Padre e hijo compartan un valor que ambos pueden cambiar model() 6
El padre llame a un método del hijo o toque un elemento del DOM viewChild() 7
El padre decida parte del HTML que muestra el hijo ng-content 8
Un componente inspeccione lo que le han proyectado contentChild() 9

Todas son señales o se apoyan en ellas, salvo output() y ng-content. Por eso este módulo viene justo después del de señales: casi todo lo que aprendiste allí se aplica aquí sin cambios.

📘 Concepto: La interfaz pública de un componente son sus entradas, sus salidas y los métodos públicos que ofrezca. Es lo único que debe conocer quien lo usa. Si respetas esa frontera, puedes reescribir el interior de un componente sin tocar a sus padres.


2. input(): datos de padre a hijo

Una entrada (input) es una propiedad del componente que se rellena desde fuera. Se declara con la función input(), que devuelve una señal de solo lectura.

Una tarjeta de empleado

// src/app/tarjeta-empleado/tarjeta-empleado.ts
import { Component, computed, input } from '@angular/core';

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  departamento: string;
}

@Component({
  selector: 'app-tarjeta-empleado',
  template: `
    <article class="tarjeta">
      <span class="avatar">{{ iniciales() }}</span>
      <div>
        <h3>{{ empleado().nombre }} {{ empleado().apellidos }}</h3>
        <p>{{ empleado().oficio }} · {{ empleado().departamento }}</p>
        @if (mostrarSalario()) {
          <p class="salario">{{ salarioAnual() }} € al año</p>
        }
      </div>
    </article>
  `,
  styles: `
    .tarjeta {
      display: flex;
      gap: 1rem;
      align-items: center;
      padding: 0.75rem 1rem;
      border: 1px solid #c5cae9;
      border-radius: 8px;
    }
    .avatar {
      width: 3rem;
      height: 3rem;
      border-radius: 50%;
      display: grid;
      place-items: center;
      background: #3f51b5;
      color: #fff;
    }
    h3, p { margin: 0; }
    .salario { color: #1a237e; font-weight: 600; }
  `,
})
export class TarjetaEmpleado {
  // Obligatoria: el padre tiene que pasarla
  readonly empleado = input.required<Empleado>();
  // Opcional: si el padre no la pasa, vale false
  readonly mostrarSalario = input(false);

  // Valores derivados de una entrada: se recalculan solos
  protected readonly iniciales = computed(() => {
    const { nombre, apellidos } = this.empleado();
    return nombre[0] + apellidos[0];
  });
  protected readonly salarioAnual = computed(() => this.empleado().salario * 14);
}

Y un padre que la usa:

// src/app/panel-entradas/panel-entradas.ts
import { Component, signal } from '@angular/core';
import { Empleado, TarjetaEmpleado } from '../tarjeta-empleado/tarjeta-empleado';

@Component({
  selector: 'app-panel-entradas',
  imports: [TarjetaEmpleado],
  template: `
    <h2>Plantilla destacada</h2>
    <app-tarjeta-empleado [empleado]="ana" [mostrarSalario]="verSalario()" />
    <app-tarjeta-empleado [empleado]="luis" [mostrarSalario]="verSalario()" />
    <button type="button" (click)="alternar()">
      {{ verSalario() ? 'Ocultar' : 'Mostrar' }} salarios
    </button>
  `,
})
export class PanelEntradas {
  protected readonly ana: Empleado = {
    empId: 100,
    nombre: 'Ana',
    apellidos: 'García López',
    oficio: 'DIRECTOR GENERAL',
    salario: 6500,
    departamento: 'DIRECCIÓN',
  };
  protected readonly luis: Empleado = {
    empId: 101,
    nombre: 'Luis',
    apellidos: 'Martínez Ruiz',
    oficio: 'DIRECTOR',
    salario: 4800,
    departamento: 'VENTAS',
  };
  protected readonly verSalario = signal(true);

  protected alternar() {
    this.verSalario.update((v) => !v);
  }
}

Dos tarjetas de empleado con avatar, nombre, oficio y salario anual, y un botón para ocultar los salarios

Figura 2. Dos instancias del mismo componente con entradas distintas. La primera lleva además el resaltado que añadiremos en la sección 3.

Fíjate en cuatro cosas:

  • [empleado]="ana" es un enlace de propiedad, igual que los del módulo 04. Angular evalúa la expresión del padre y pasa el resultado a la entrada empleado del hijo.
  • El hijo lee la entrada llamándola: empleado(). Es una señal, así que empleado sin paréntesis es la señal, no el valor.
  • Las entradas son públicas y readonly. Son parte de la interfaz del componente, por eso no llevan protected.
  • El padre tiene que importar al hijo en imports para poder usar su selector.

Opcional u obligatoria

Hay dos formas de declarar una entrada, y la diferencia afecta a los tipos:

Declaración Tipo que lees Si el padre no la pasa
input(false) boolean Vale false
input<string>() string \| undefined Vale undefined
input<number \| null>(null) number \| null Vale null
input.required<Empleado>() Empleado Error de compilación NG8008

Usa input.required cuando el componente no tenga sentido sin ese dato. Una tarjeta de empleado sin empleado no puede pintar nada, y marcar la entrada como obligatoria te ahorra llenar la plantilla de @if (empleado()). El compilador comprueba cada uso:

X [ERROR] NG8008: Required input 'obligatoria' from component H must be specified.

Con input(valor) TypeScript deduce el tipo del valor inicial. Si quieres admitir más tipos que el del valor, indícalo: input<number | null>(null).

Enlace o texto literal

Hay dos maneras de pasar un valor, y confundirlas es un error típico:

<!-- Con corchetes: lo de dentro es una expresión TypeScript -->
<app-aviso [mensaje]="textoAviso()" />
<app-tarjeta-empleado [mostrarSalario]="true" />

<!-- Sin corchetes: el valor es siempre el texto literal -->
<app-aviso mensaje="Revisa los datos" />

Sin corchetes, mostrarSalario="true" pasa el texto "true", no el booleano, y el compilador lo rechaza porque la entrada espera un boolean. En la sección 3 verás cómo aceptar esta forma cuando te convenga.

Una entrada es una señal de solo lectura

input() devuelve un InputSignal<T>. Se lee como cualquier señal, y por tanto sirve dentro de computed, effect y plantillas. Lo que no tiene es set:

cambiar() {
  this.nombre.set('x');
}
X [ERROR] TS2339: Property 'set' does not exist on type 'InputSignal<string>'.

Esto no es una limitación caprichosa. Es la regla “los datos bajan” escrita en el sistema de tipos: el dueño del valor es el padre, y si el hijo pudiera escribirlo habría dos versiones de la verdad. Cuando de verdad necesites que el hijo cambie el valor, tienes model() (sección 6) o una copia local (más abajo).

Reaccionar cuando cambia una entrada

La mayoría de las veces basta con computed. Si lo que quieres es un efecto secundario (registrar algo, guardar en localStorage), usa effect, que vuelve a ejecutarse cada vez que cambia una señal que lee:

@Component({
  selector: 'app-registro-cambios',
  template: `<p>{{ id() }}</p>`,
})
export class RegistroCambios {
  readonly id = input.required<number>();
  protected readonly vistos: number[] = [];

  constructor() {
    effect(() => {
      this.vistos.push(this.id());
    });
  }
}

Con un padre que pasa 1 y luego 2, vistos acaba valiendo [1, 2].

💡 Consejo: Si escribes un effect solo para copiar una entrada en una señal, para. Eso es un computed (si es solo de lectura) o un linkedSignal (si además se edita).

Copia local editable con linkedSignal

A veces el hijo necesita un borrador: el padre le da un nombre, el usuario lo edita, y hasta que no guarde el padre no debe enterarse. Lo que buscas es un estado local que nazca de la entrada y se reinicie si la entrada cambia. Es justo lo que hace linkedSignal, que viste en el módulo 05:

@Component({
  selector: 'app-editor-nombre',
  template: `
    <input [value]="borrador()" (input)="borrador.set($any($event.target).value)" />
    <button type="button" (click)="deshacer()">Deshacer</button>
  `,
})
export class EditorNombre {
  readonly nombre = input.required<string>();
  // Empieza con el valor de la entrada y vuelve a él cuando la entrada cambia
  protected readonly borrador = linkedSignal(() => this.nombre());

  protected deshacer() {
    this.borrador.set(this.nombre());
  }
}

En la prueba, tras escribir Anita, el campo muestra Anita. Cuando el padre pasa Luis, el campo muestra Luis: la edición local se descarta porque ha llegado un dato nuevo.

Cuándo está disponible el valor

Una entrada obligatoria no tiene valor hasta que Angular procesa la plantilla del padre, y eso ocurre después de construir al hijo. Leerla directamente en el constructor o en el inicializador de una propiedad no compila:

export class Ejemplo {
  readonly a = input.required<number>();
  private readonly copia = this.a(); // error
}
X [ERROR] NG8118: `a` is a required `input` and does not have a value in this context.

El compilador caza el caso directo, pero no todos. Si la lectura queda escondida en una función auxiliar, compila y falla en ejecución:

NG0950: Input "a" is required but no value is available yet.

Dentro de computed, effect y la plantilla no hay problema, porque se ejecutan más tarde. Fíjate en que un computed se puede declarar en el inicializador, como iniciales en la tarjeta: su función no se ejecuta hasta que alguien lo lee.

⚠️ Importante: const copia = this.empleado() guardado en una propiedad sería además un error de diseño aunque compilara: tendrías una foto del primer valor, y ya no se actualizaría. Deriva con computed.

Alias

La opción alias da a la entrada un nombre externo distinto del interno:

@Component({
  selector: 'app-aviso',
  template: `<p>{{ texto() }}</p>`,
})
export class Aviso {
  readonly texto = input('', { alias: 'mensaje' });
}
<app-aviso mensaje="Hola" />

Por dentro se llama texto y por fuera mensaje. Dificulta seguir el código, porque el mismo dato tiene dos nombres, y lo normal es no usarlo. Tiene sentido al renombrar una entrada sin romper a quienes ya la usan, o en una directiva, como verás en el módulo 07.


3. Transformaciones de entrada

En HTML estás acostumbrado a escribir atributos booleanos sin valor, como <input disabled>. Si lo intentas con una entrada normal:

<app-tarjeta-empleado [empleado]="ana" destacada />
X [ERROR] TS2322: Type 'string' is not assignable to type 'boolean'.

Un atributo sin valor llega como cadena vacía, y destacada espera un booleano. La opción transform convierte lo que llega antes de guardarlo en la señal.

booleanAttribute y numberAttribute

Angular trae las dos transformaciones más habituales, que son funciones corrientes de @angular/core:

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

export class TarjetaEmpleado {
  readonly empleado = input.required<Empleado>();
  readonly mostrarSalario = input(false);
  readonly destacada = input(false, { transform: booleanAttribute });
  readonly pagas = input(14, { transform: numberAttribute });
  // ...
  protected readonly salarioAnual = computed(() => this.empleado().salario * this.pagas());
}

Ahora las dos líneas siguientes compilan, y dan el resultado de la figura 2: Ana sale resaltada, y a Luis se le calcula el salario con 12 pagas (4800 × 12 = 57 600).

<app-tarjeta-empleado [empleado]="ana" [mostrarSalario]="verSalario()" destacada />
<app-tarjeta-empleado [empleado]="luis" [mostrarSalario]="verSalario()" pagas="12" />

Sus reglas caben en una línea cada una:

Función Qué devuelve
booleanAttribute(v) Si v es booleano, v. Si no, true, salvo que sea null, undefined o "false", que dan false
numberAttribute(v, porDefecto?) El número, si v se puede interpretar como tal. Si no, porDefecto, o NaN si no lo has indicado

Por tanto, destacada, destacada="" y destacada="sí" activan la entrada, mientras que destacada="false" y [destacada]="null" no.

Transformaciones propias

Una transformación es cualquier función pura que recibe un valor y devuelve otro. Con ella puedes normalizar lo que llega. Esta barra de stock recorta los espacios de la etiqueta, y da un valor de reserva al máximo:

// src/app/barra-stock/barra-stock.ts
import { booleanAttribute, Component, computed, input, numberAttribute } from '@angular/core';

function recortar(valor: string | null | undefined): string {
  return valor?.trim() ?? '';
}

@Component({
  selector: 'app-barra-stock',
  template: `
    <div class="fila" [class.compacta]="compacta()">
      <span>{{ etiqueta() }}</span>
      <div class="pista"><div class="relleno" [style.width.%]="porcentaje()"></div></div>
      <span>{{ valor() }} / {{ maximo() }}</span>
    </div>
  `,
  styles: `
    .fila {
      display: grid;
      grid-template-columns: 10rem 1fr 5rem;
      gap: 0.75rem;
      align-items: center;
      padding: 0.5rem 0;
    }
    .compacta { font-size: 0.8rem; padding: 0.1rem 0; }
    .pista { background: #eceff1; border-radius: 4px; height: 0.75rem; overflow: hidden; }
    .relleno { background: #3f51b5; height: 100%; }
  `,
})
export class BarraStock {
  readonly etiqueta = input('', { transform: recortar });
  readonly valor = input(0, { transform: numberAttribute });
  readonly maximo = input(100, { transform: (v: unknown) => numberAttribute(v, 100) });
  readonly compacta = input(false, { transform: booleanAttribute });

  protected readonly porcentaje = computed(() =>
    Math.min(100, (this.valor() / this.maximo()) * 100),
  );
}
// src/app/panel-stock/panel-stock.ts
@Component({
  selector: 'app-panel-stock',
  imports: [BarraStock],
  template: `
    <h2>Stock del almacén</h2>
    <app-barra-stock etiqueta="  Portátil UltraBook 14 " valor="25" maximo="40" />
    <app-barra-stock etiqueta="Ratón inalámbrico" [valor]="unidades()" />
    <app-barra-stock etiqueta="Monitor 24 FHD" valor="abc" />
    <app-barra-stock etiqueta="Pack teclado y ratón" valor="12" maximo="sin dato" compacta />
    <button type="button" (click)="reponer()">Reponer ratones</button>
  `,
})
export class PanelStock {
  protected readonly unidades = signal(35);

  protected reponer() {
    this.unidades.update((u) => u + 10);
  }
}

Cuatro barras de stock. La del Monitor muestra NaN de 100 con la barra llena, y la del Pack aparece más pequeña

Figura 3. Cuatro usos de la barra. La tercera recibe valor="abc" y muestra NaN con la barra llena: numberAttribute sin valor por defecto no te protege de un dato inválido.

La figura enseña algo que conviene mirar con calma:

  • Primera fila: " Portátil UltraBook 14 " sale sin espacios, y las cadenas "25" y "40" se han convertido en números (62,5 %).
  • Segunda fila: con corchetes llega ya un número, y numberAttribute lo deja igual. Las transformaciones se aplican a todo valor que llegue, no solo a los literales.
  • Tercera fila: valor="abc" da NaN. El cálculo del ancho da NaN, Angular no aplica ningún ancho, y la barra se ve llena. Es un fallo silencioso.
  • Cuarta fila: maximo="sin dato" cae en el valor de reserva, 100, porque la transformación es (v: unknown) => numberAttribute(v, 100). Así se evita el problema de la tercera fila.

El tipo de una transformación

Una entrada con transformación tiene dos tipos: lo que acepta y lo que guarda. En input(0, { transform: numberAttribute }), el padre puede pasar cualquier cosa (unknown) y el hijo lee un number. El compilador usa el primero para comprobar la plantilla del padre, y por eso etiqueta admite string | null | undefined aunque dentro siempre sea string.

Con una función en línea sin anotar, o con input.required, indica los dos tipos a mano, primero el guardado y después el aceptado:

readonly a = input<number, unknown>(0, { transform: (v) => numberAttribute(v, 0) });
readonly b = input.required<number, unknown>({ transform: (v) => numberAttribute(v, 0) });

⚠️ Importante: Una transformación debe ser pura y rápida: se ejecuta cada vez que llega un valor, durante la actualización de la vista. Nada de peticiones, ni de leer otras señales, ni de efectos secundarios. Si el resultado depende de otra entrada, usa un computed, que para eso existe.


4. Objetos en entradas: no los mutes 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection se comprueba siempre que se revisa su padre, así que un objeto mutado llega a verse en el hijo; en 22 usa OnPush y no se ve. Si usas la LTS: añade changeDetection: ChangeDetectionStrategy.OnPush a cada componente y no mutes nunca los objetos recibidos: tendrás el mismo comportamiento que aquí.

Cuando pasas un objeto por una entrada no se copia: padre e hijo comparten la misma referencia. Esto invita a una tentación: como es el mismo objeto, ¿por qué no cambiar directamente una propiedad?

// src/app/panel-salario/panel-salario.ts
@Component({
  selector: 'app-ficha-salario',
  template: `<p class="hija">La hija ve: <strong>{{ empleado().salario }} €</strong></p>`,
})
export class FichaSalario {
  readonly empleado = input.required<Empleado>();
}

@Component({
  selector: 'app-panel-salario',
  imports: [FichaSalario],
  template: `
    <h2>Subida de salario</h2>
    <p class="madre">La madre ve: <strong>{{ empleado().salario }} €</strong></p>
    <app-ficha-salario [empleado]="empleado()" />
    <button type="button" (click)="subirMutando()">Subir mutando (mal)</button>
    <button type="button" (click)="subirConCopia()">Subir con copia (bien)</button>
  `,
})
export class PanelSalario {
  protected readonly empleado = signal<Empleado>({
    empId: 101,
    nombre: 'Luis',
    apellidos: 'Martínez Ruiz',
    oficio: 'DIRECTOR',
    salario: 4800,
    departamento: 'VENTAS',
  });

  protected subirMutando() {
    this.empleado().salario += 100;
  }

  protected subirConCopia() {
    this.empleado.update((e) => ({ ...e, salario: e.salario + 100 }));
  }
}

El panel muestra 5000 euros en la madre y 4800 euros en la hija después de pulsar dos veces el botón de mutar

Figura 4. Tras pulsar dos veces “Subir mutando”: la madre ve 5000 €, y la hija sigue en 4800 €. Las dos leen el mismo objeto.

Este es el comportamiento real, y es desconcertante. Las dos partes leen el mismo objeto, pero muestran cifras distintas. Esto es lo que ha pasado por dentro:

  1. El clic ocurre en una plantilla de la madre, de modo que Angular marca esa vista para revisarla.
  2. Angular repinta la madre. Su {{ empleado().salario }} lee el objeto mutado y muestra 5000.
  3. Al llegar a [empleado]="empleado()", compara el valor nuevo con el anterior. Es la misma referencia, así que para Angular la entrada no ha cambiado.
  4. La señal empleado no ha notificado nada, porque nadie llamó a set ni a update. El hijo no tiene motivo para repintarse y se salta.

Con “Subir con copia”, update guarda un objeto nuevo. La señal notifica, la referencia cambia, la entrada del hijo se actualiza y las dos partes vuelven a coincidir. En la prueba, tras una mutación (la madre en 4900 € y la hija en 4800 €) y una copia, las dos muestran 5000 €.

  subirMutando() subirConCopia()
Referencia del objeto La misma Nueva
¿Notifica la señal? No Sí
¿Lo ve la madre? Solo porque el clic fue suyo Sí
¿Lo ve la hija? No Sí

Copiar con { ...e, salario } es la misma inmutabilidad del módulo 05. Lo comprobamos además con tres hijos iguales salvo por su estrategia, tras una mutación de 4800 a 4900:

Estrategia del hijo Angular 22 Angular 21
Sin indicar 4800 4900
ChangeDetectionStrategy.OnPush 4800 4800
ChangeDetectionStrategy.Eager 4900 4900

Eager hace que el hijo se revise siempre, pero no arregla nada: el código sigue dependiendo de que algo provoque esa revisión. La solución es no mutar. La detección de cambios se estudia a fondo en el módulo 09.

📘 Concepto: Trata todo lo que llega por una entrada como si fuera de solo lectura, aunque TypeScript te deje cambiarlo. Puedes hacerlo explícito con input.required<Readonly<Empleado>>(), y así el compilador rechazará la mutación en el hijo.


5. output(): eventos de hijo a padre

Una salida (output) permite a un hijo avisar de que ha pasado algo. El hijo la declara con output() y la dispara con emit. El padre la escucha con la misma sintaxis ( ) de los eventos del DOM.

Un catálogo con carrito

// src/app/fila-producto/fila-producto.ts
import { Component, input, output } from '@angular/core';

export interface Producto {
  productoId: number;
  nombre: string;
  precio: number;
  stock: number;
}

@Component({
  selector: 'app-fila-producto',
  template: `
    <span class="nombre">{{ producto().nombre }}</span>
    <span>{{ producto().precio }} €</span>
    <button type="button" [disabled]="producto().stock === 0" (click)="anadir.emit(producto())">
      {{ producto().stock === 0 ? 'Agotado' : 'Añadir' }}
    </button>
    <button type="button" (click)="descartar.emit()">Descartar</button>
  `,
  styles: `
    :host {
      display: grid;
      grid-template-columns: 1fr 5rem 5.5rem 6rem;
      gap: 0.5rem;
      align-items: center;
      padding: 0.4rem 0;
      border-bottom: 1px solid #eceff1;
    }
    .nombre { font-weight: 600; }
  `,
})
export class FilaProducto {
  readonly producto = input.required<Producto>();
  // Emite el producto al padre
  readonly anadir = output<Producto>();
  // Sin tipo: es un aviso, no lleva datos
  readonly descartar = output();
}
// src/app/catalogo/catalogo.ts
import { Component, computed, signal } from '@angular/core';
import { FilaProducto, Producto } from '../fila-producto/fila-producto';

const PRODUCTOS: Producto[] = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
  { productoId: 2, nombre: 'Portátil Gaming 16', precio: 1499, stock: 10 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9, stock: 0 },
];

@Component({
  selector: 'app-catalogo',
  imports: [FilaProducto],
  template: `
    <h2>Catálogo</h2>
    @for (producto of productos(); track producto.productoId) {
      <app-fila-producto
        [producto]="producto"
        (anadir)="alAnadir($event)"
        (descartar)="alDescartar(producto.productoId)"
      />
    } @empty {
      <p>No quedan productos.</p>
    }
    <p class="total">{{ unidades() }} unidades · {{ importe() }} €</p>
  `,
})
export class Catalogo {
  protected readonly productos = signal(PRODUCTOS);
  protected readonly carrito = signal<Producto[]>([]);
  protected readonly unidades = computed(() => this.carrito().length);
  protected readonly importe = computed(() =>
    this.carrito().reduce((suma, p) => suma + p.precio, 0),
  );

  protected alAnadir(producto: Producto) {
    this.carrito.update((c) => [...c, producto]);
  }

  protected alDescartar(id: number) {
    this.productos.update((lista) => lista.filter((p) => p.productoId !== id));
  }
}

Catálogo con tres productos, botones de añadir y descartar, y un total de 2 unidades y 2398 euros

Figura 5. Tras añadir los dos portátiles. El ratón no tiene stock y su botón está desactivado. La fila no sabe que existe un carrito: solo avisa.

Cada parte hace lo suyo:

  • output<Producto>() declara una salida que lleva un Producto. output() sin tipo se usa para un aviso sin datos, y se dispara con emit() sin argumentos.
  • anadir.emit(producto()) entrega el valor a quien esté escuchando.
  • (anadir)="alAnadir($event)" escucha. $event es lo que se emitió, no un Event del DOM. Aquí es un Producto.
  • (descartar)="alDescartar(producto.productoId)" no necesita $event, porque dentro del @for el padre ya sabe de qué producto se trata.

La fila no conoce el carrito, ni el precio total, ni que exista una lista. Puedes usarla en un buscador, en una lista de favoritos o en un pedido, y en cada sitio el padre decidirá qué significa “añadir”.

El tipo de $event se comprueba. Si el manejador espera otra cosa, el compilador protesta:

X [ERROR] TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

Qué es una salida por dentro

output() devuelve un OutputEmitterRef<T>, un objeto muy pequeño con dos métodos: emit(valor) y subscribe(funcion). No es una señal: no guarda valor y no se lee con (). Emitir es llamar ahora mismo a quienes escuchan, de forma síncrona. Si nadie escucha, el valor se pierde sin más.

Normalmente nunca llamas a subscribe, porque el ( ) de la plantilla lo hace por ti y Angular limpia la suscripción al destruir el componente. Lo necesitas cuando creas un componente por código, como en el módulo 03:

const ref = this.contenedor.createComponent(FilaProducto);
ref.setInput('producto', producto);

const suscripcion = ref.instance.anadir.subscribe((p) => console.log(p.nombre));
// Cuando ya no interese:
suscripcion.unsubscribe();

output() no depende de RxJS. Si ya tienes un Observable y quieres convertirlo en salida, existe outputFromObservable, que verás en el módulo 15.

Las salidas no burbujean

Los eventos del DOM suben por el árbol: un clic en un botón llega también a su <div>. Las salidas de Angular no. Solo las escucha el padre directo. Observa este caso, donde el abuelo intenta escuchar anadir, que es una salida de su nieto:

<!-- Plantilla de un componente intermedio, app-mid -->
<app-fila-producto [producto]="producto" />

<!-- Plantilla del abuelo -->
<app-mid (anadir)="registrar($event)" />

Esto compila y registrar no se llama nunca. Compila porque (evento) sobre un elemento que no tiene esa salida se interpreta como un evento del DOM con ese nombre, y nadie lanza un evento DOM llamado anadir. El mismo motivo hace que una errata en el nombre de una salida no dé ningún error:

<!-- Compila, y nunca se ejecuta: la salida se llama anadir -->
<app-fila-producto [producto]="producto" (anadido)="alAnadir($event)" />

⚠️ Importante: A diferencia de las entradas, el compilador no te avisa de una salida mal escrita. Si un botón “no hace nada”, comprueba primero el nombre entre paréntesis.

Para cruzar varios niveles tienes dos caminos: que cada intermedio reemita la salida, o sacar el estado a un servicio compartido, que verás en el módulo 08 y el módulo 19. Para uno o dos niveles, reemitir es correcto. Para más, es un síntoma de que el estado debería estar en otro sitio.

Nombres de las salidas

  • Sin prefijo on. La plantilla ya dice que es un evento: (guardar) se lee mejor que (onGuardar).
  • En el proyecto, el manejador del padre sí puede describir la reacción: (anadir)="alAnadir($event)".
  • Nunca el nombre de un evento nativo. Mira lo que pasa con esta salida llamada click:
@Component({
  selector: 'app-boton-mal',
  template: `<button type="button" (click)="click.emit('propio')">Pulsar</button>`,
})
export class BotonMal {
  readonly click = output<string>();
}

Si el padre escribe <app-boton-mal (click)="registrar($event)" />, registrar se llama dos veces con un solo clic: una con 'propio', y otra con el evento click nativo, que burbujea desde el botón hasta <app-boton-mal>. En la prueba, los dos valores recibidos son "propio" y un evento de tipo click.

Alias en salidas

Como en las entradas, existe alias, y la recomendación es la misma: evítalo salvo para no romper código existente.

readonly pulsado = output<number>({ alias: 'accion' });
<app-boton-alias (accion)="registrar($event)" />

Emitir después de destruir

Si un temporizador sigue vivo cuando el componente ya no existe y llama a emit, no se lanza ninguna excepción: la emisión se ignora, y en la consola aparece un aviso NG0953 (Unexpected emit for destroyed OutputRef). Si lo ves, te falta limpiar ese temporizador con DestroyRef, como en el módulo 03.


6. model(): enlace en los dos sentidos

Hay componentes cuyo trabajo consiste en editar un valor: un selector de cantidad, un interruptor, unas estrellas de valoración. Con lo que sabes, necesitarías una entrada y una salida para cada valor, y el mismo código repetido en cada padre. model() lo resuelve de una vez.

Un selector de cantidad

// src/app/selector-cantidad/selector-cantidad.ts
import { Component, input, model } from '@angular/core';

@Component({
  selector: 'app-selector-cantidad',
  template: `
    <button type="button" [disabled]="cantidad() <= minimo()" (click)="restar()">-</button>
    <output>{{ cantidad() }}</output>
    <button type="button" [disabled]="cantidad() >= maximo()" (click)="sumar()">+</button>
  `,
  styles: `
    :host { display: inline-flex; gap: 0.5rem; align-items: center; }
    output { min-width: 2rem; text-align: center; font-weight: 600; }
  `,
})
export class SelectorCantidad {
  // Señal de escritura compartida con el padre
  readonly cantidad = model(1);
  readonly minimo = input(1);
  readonly maximo = input(10);

  protected sumar() {
    this.cantidad.update((c) => c + 1);
  }

  protected restar() {
    this.cantidad.update((c) => c - 1);
  }
}

model(1) devuelve un ModelSignal<number>: una señal escribible, con set y update, que además hace de entrada y de salida. En el padre se usa con la sintaxis [( )], que por su forma se suele llamar “plátano en una caja” (banana in a box):

// src/app/valoracion/valoracion.ts
@Component({
  selector: 'app-valoracion',
  template: `
    @for (n of opciones; track n) {
      <button type="button" [class.llena]="n <= estrellas()" [attr.aria-label]="n + ' estrellas'"
        (click)="estrellas.set(n)">★</button>
    }
  `,
  styles: `
    button {
      border: 0;
      background: none;
      font-size: 1.5rem;
      color: #cfd8dc;
      cursor: pointer;
    }
    .llena { color: #ff7043; }
  `,
})
export class Valoracion {
  // Obligatorio: el padre debe enlazarlo
  readonly estrellas = model.required<number>();
  protected readonly opciones = [1, 2, 3, 4, 5];
}
// src/app/linea-pedido/linea-pedido.ts
@Component({
  selector: 'app-linea-pedido',
  imports: [SelectorCantidad, Valoracion],
  template: `
    <h2>Línea de pedido</h2>
    <p>Monitor 24 FHD a {{ precio }} €</p>
    <app-selector-cantidad [(cantidad)]="cantidad" [maximo]="5" />
    <p class="total">Total: {{ total() }} €</p>
    <button type="button" (click)="cantidad.set(1)">Volver a 1 desde el padre</button>
    <h3>Tu opinión</h3>
    <app-valoracion [(estrellas)]="nota" />
    <p>Has dado {{ nota() }} de 5 estrellas.</p>
  `,
})
export class LineaPedido {
  protected readonly precio = 139;
  protected readonly cantidad = signal(2);
  protected readonly nota = signal(3);
  protected readonly total = computed(() => this.cantidad() * this.precio);
}

Línea de pedido con cantidad 3, total de 417 euros y cinco estrellas marcadas

Figura 6. Tras pulsar “+” y la quinta estrella. El hijo ha escrito en su modelo, y la señal del padre y el computed que depende de ella se han actualizado.

Qué significa [( )]

[(cantidad)]="cantidad" no es magia, es una abreviatura de las dos sintaxis que ya conoces. Las dos líneas siguientes hacen lo mismo:

<app-selector-cantidad [(cantidad)]="cantidad" />

<app-selector-cantidad [cantidad]="cantidad()" (cantidadChange)="cantidad.set($event)" />

Por tanto, cantidad = model(1) crea dos cosas: una entrada llamada cantidad, y una salida llamada cantidadChange.

Diagrama que muestra que los corchetes y paréntesis equivalen a una entrada más una salida llamada cantidadChange

Figura 7. El doble enlace es una entrada y una salida unidas por una convención de nombres.

Atención al detalle de la figura: cantidadChange solo se emite cuando escribe el hijo. Lo comprobamos con un padre que escucha la salida:

Acción cantidad en el hijo ¿Se emite cantidadChange?
El padre hace c.set(5) 5 No
El hijo pulsa “+” 6 Sí, con 6

Tiene sentido: la salida avisa al padre de un cambio que no conoce, y el padre ya conoce los suyos. Así no hay bucles.

Como model() está formado por esas dos piezas, también puedes usar solo una:

<!-- Solo baja: el hijo cambia su copia, y el padre no se entera -->
<app-selector-cantidad [cantidad]="3" />

<!-- Con un manejador propio -->
<app-selector-cantidad [cantidad]="linea().cantidad" (cantidadChange)="cambiarCantidad($event)" />

En el primer caso, el padre sigue con 3 mientras el hijo avanza a 4: el modelo funciona como un estado local que empieza con el valor recibido.

El segundo caso es el que necesitas si el valor es una propiedad de un objeto guardado en una señal. [(cantidad)] no puede escribir dentro de esa señal, así que le das tú la forma de hacerlo sin mutar:

export class FormaLarga {
  protected readonly linea = signal({ producto: 'Monitor', cantidad: 3 });

  protected cambiarCantidad(cantidad: number) {
    this.linea.update((l) => ({ ...l, cantidad }));
  }
}

Qué se puede enlazar

Expresión en [(x)]="..." ¿Vale?
Una señal escribible: [(cantidad)]="cantidad" Sí, y es lo recomendable
Una propiedad normal: [(cantidad)]="plana" Sí, Angular escribe en plana
La lectura de una señal: [(cantidad)]="cantidad()" No

El tercer caso es el error más frecuente de este apartado, porque en el resto de plantillas se escribe cantidad():

X [ERROR] NG5002: Unsupported expression in a two-way binding

El motivo es claro si recuerdas la forma larga: Angular necesita algo donde escribir, y cantidad() es solo un valor. Por eso en el doble enlace pasas la señal, sin paréntesis.

Cuándo usar model y cuándo input + output

model() se reserva para los casos en los que el componente edita un valor, y ese valor es del padre. Si el hijo necesita validar la acción, o lo que sube no es lo mismo que lo que baja, usa entrada y salida:

Situación Usa
Interruptor, selector, valoración, paginador model()
Lista que recibe empleados y avisa de cuál se ha elegido input() + output()
Formulario que recibe un empleado y emite los cambios al guardar input() + output()

Dos diferencias más frente a input():

  • model() no admite transform. Sus opciones son solo alias y debugName, porque una transformación solo convertiría en un sentido.
  • Un model() no se puede considerar un dato de solo lectura: cualquiera que tenga la referencia puede escribir en él. Un input() lo garantiza.

💡 Consejo: El [(ngModel)] de los formularios usa exactamente el mismo mecanismo, una entrada ngModel y una salida ngModelChange. Lo verás en el módulo 16.


7. viewChild y viewChildren

Las entradas y salidas son declarativas: describes qué conexión hay. Algunas cosas no encajan en ese molde. Poner el foco en un campo, medir un elemento, o decirle a un cronómetro que empiece son órdenes. Para esos casos, un componente puede obtener una referencia a algo de su propia plantilla mediante una consulta (query).

viewChild devuelve una señal cuyo valor es lo encontrado. Se actualiza sola si lo encontrado aparece o desaparece.

Llamar a un método del hijo

// src/app/cronometro/cronometro.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-cronometro',
  template: `<span class="tiempo">{{ segundos() }} s</span>`,
  styles: `.tiempo { font-size: 2rem; font-weight: 600; color: #1a237e; }`,
})
export class Cronometro {
  protected readonly segundos = signal(0);
  private temporizador?: ReturnType<typeof setInterval>;

  constructor() {
    inject(DestroyRef).onDestroy(() => this.parar());
  }

  // API pública: lo que el padre puede pedirle
  iniciar() {
    this.parar();
    this.temporizador = setInterval(() => this.segundos.update((s) => s + 1), 1000);
  }

  parar() {
    clearInterval(this.temporizador);
  }

  reiniciar() {
    this.parar();
    this.segundos.set(0);
  }
}
// src/app/panel-cronometro/panel-cronometro.ts
import { Component, viewChild } from '@angular/core';
import { Cronometro } from '../cronometro/cronometro';

@Component({
  selector: 'app-panel-cronometro',
  imports: [Cronometro],
  template: `
    <h2>Fichaje de la jornada</h2>
    <app-cronometro #reloj />
    <p>
      <button type="button" (click)="cronometro().iniciar()">Iniciar</button>
      <button type="button" (click)="cronometro().parar()">Parar</button>
      <button type="button" (click)="reloj.reiniciar()">Reiniciar</button>
    </p>
  `,
})
export class PanelCronometro {
  // Busca en la plantilla el primer Cronometro
  protected readonly cronometro = viewChild.required(Cronometro);
}

Hay aquí dos maneras de llegar al mismo objeto:

  • viewChild.required(Cronometro) lo busca por clase y lo deja en una propiedad, útil desde el código TypeScript. required significa que siempre estará, de modo que el tipo es Cronometro y no Cronometro | undefined.
  • #reloj es una variable de plantilla, del módulo 04. Sobre un componente, vale la instancia del componente. Si solo la usas en la plantilla, no necesitas consulta alguna.

En la prueba, tras pulsar Iniciar y pasar tres segundos, se muestra 3 s; tras Parar, deja de avanzar; y tras Reiniciar, vuelve a 0 s. En el navegador, el cronómetro mostró 2 s tras algo más de dos segundos de espera.

El padre solo puede usar lo público: segundos es protected y por tanto queda fuera de su alcance. La frontera sigue en pie, y iniciar, parar y reiniciar pasan a formar parte de la interfaz del componente.

Acceder a un elemento del DOM

Para encontrar un elemento de la plantilla se le pone una variable de plantilla, y se busca por su nombre, en forma de cadena:

// src/app/buscador/buscador.ts
import { afterNextRender, Component, ElementRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-buscador',
  template: `
    <label>Buscar <input #campo type="search" /></label>
    <button type="button" (click)="limpiar()">Limpiar</button>
  `,
})
export class Buscador {
  private readonly campo = viewChild.required<ElementRef<HTMLInputElement>>('campo');

  constructor() {
    // El elemento ya existe cuando termina el primer render
    afterNextRender(() => this.campo().nativeElement.focus());
  }

  protected limpiar() {
    const entrada = this.campo().nativeElement;
    entrada.value = '';
    entrada.focus();
  }
}

El resultado es un ElementRef, una envoltura alrededor del elemento real, que está en nativeElement. El tipo genérico (ElementRef<HTMLInputElement>) es lo que hace que focus() y value tengan tipos correctos. En Chromium, al cargar la página, document.activeElement era el INPUT.

Qué busca una consulta y qué devuelve

El primer argumento dice qué buscar: una clase (de un componente o una directiva), un token, o el nombre de una variable de plantilla. La opción read dice qué devolver de lo encontrado. Esto se entiende mejor con un mismo componente leído de dos formas:

@Component({
  selector: 'app-lectura-read',
  imports: [Cronometro],
  template: `<app-cronometro #reloj />`,
})
export class LecturaRead {
  readonly instancia = viewChild.required<Cronometro>('reloj');
  readonly elemento = viewChild.required('reloj', { read: ElementRef });
}

instancia() es el objeto Cronometro, y elemento().nativeElement es el elemento <app-cronometro> del DOM (tagName vale APP-CRONOMETRO en la prueba).

Cuándo tiene valor

Las consultas se resuelven al crear la vista, y eso ocurre después del constructor. Es la misma situación que con las entradas obligatorias, con mensajes parecidos:

Dónde lees viewChild.required(...) Resultado
Directamente en el constructor Error de compilación NG8118
Escondido en una función llamada desde el constructor NG0951 en ejecución
Si en la plantilla no hay nada que encaje NG0951 al leerlo
En plantilla, computed, effect, eventos Correcto
NG0951: Child query result is required but no value is available.

Si el elemento está dentro de un @if, puede no existir, y ahí no sirve required. Usa la versión normal y gestiona undefined:

@Component({
  selector: 'app-condicional',
  template: `
    <button type="button" (click)="visible.update((v) => !v)">Alternar</button>
    @if (visible()) {
      <input #nota value="Nota" />
    }
  `,
})
export class Condicional {
  readonly visible = signal(false);
  readonly nota = viewChild<ElementRef<HTMLInputElement>>('nota');

  constructor() {
    effect(() => {
      this.nota()?.nativeElement.focus();
    });
  }
}

Como la consulta es una señal, el effect se vuelve a ejecutar cuando el campo aparece. En la prueba registramos lo que veía: undefined en la primera ejecución, un objeto tras pulsar Alternar, y el foco quedó en el INPUT. Al ocultarlo, nota() vuelve a ser undefined. Con las antiguas consultas por decorador había que combinar ngAfterViewInit y comprobaciones a mano. Con señales, esto es un efecto de tres líneas.

viewChildren: varios resultados

viewChildren devuelve una señal con un array de solo lectura. También se actualiza sola:

@Component({
  selector: 'app-lista-tarjetas',
  imports: [TarjetaEmpleado],
  template: `
    @for (e of empleados(); track e.empId) {
      <app-tarjeta-empleado [empleado]="e" />
    }
    <p>Tarjetas en pantalla: {{ total() }}</p>
    <button type="button" (click)="quitar()">Quitar la última</button>
  `,
})
export class ListaTarjetas {
  protected readonly empleados = signal(EMPLEADOS);
  private readonly tarjetas = viewChildren(TarjetaEmpleado);
  protected readonly total = computed(() => this.tarjetas().length);

  protected quitar() {
    this.empleados.update((lista) => lista.slice(0, -1));
  }
}

Con dos empleados dice Tarjetas en pantalla: 2, y tras “Quitar la última”, 1.

⚠️ Importante: No leas las entradas de un hijo desde la plantilla del padre a través de una consulta. Si añades primera = computed(() => this.tarjetas()[0]?.empleado().nombre) y pintas {{ primera() }}, falla con NG0950: Input "empleado" is required but no value is available yet. El padre evalúa su plantilla antes de que el @for haya entregado las entradas a sus hijos. La solución además es más sencilla: el padre ya tiene el dato, empleados()[0]?.nombre.

Usa las consultas con mesura

Una consulta acopla al padre con el interior del hijo, y hace que el flujo de datos deje de verse en la plantilla. Antes de escribir una, piensa si no basta con un dato:

En lugar de… Prueba con…
formulario().mostrarErrores() <app-formulario [mostrarErrores]="enviado()" />
panel().abrir() <app-panel [(abierto)]="abierto" />
Leer hijo().valor() desde el padre Una salida, o un model()

Las consultas son la herramienta correcta para lo imperativo de verdad: foco, scroll, medir, reproducir un vídeo, o integrarse con una biblioteca que pide un elemento del DOM.


8. Proyección de contenido con ng-content

Las entradas pasan datos. Pero muchas veces lo que quieres pasar es HTML. Piensa en una tarjeta genérica con cabecera, cuerpo y pie: cada vez lleva cosas distintas, a veces texto y a veces un formulario. Pasarlo como cadena por una entrada sería absurdo. <ng-content> marca un hueco en la plantilla del hijo, que se rellena con lo que el padre escriba entre las etiquetas. Esto se llama proyección de contenido.

@Component({
  selector: 'app-tarjeta-simple',
  template: `
    <div class="tarjeta">
      <ng-content />
    </div>
  `,
})
export class TarjetaSimple {}
<app-tarjeta-simple>
  <h3>Aviso</h3>
  <p>Mañana se cierra la nómina.</p>
</app-tarjeta-simple>

Es como el children de React o el <slot> de Vue y de los Web Components. Hay algo importante: el contenido sigue siendo del padre. Sus enlaces se evalúan contra la clase del padre, y sus estilos encapsulados son los del padre, no los del componente que lo muestra. El hijo solo decide dónde se coloca.

Varias ranuras con select

Con un solo hueco no llegas muy lejos. El atributo select admite un selector CSS, y cada <ng-content> recoge lo que encaje con él. El <ng-content> sin select recoge todo lo demás:

// src/app/caja/caja.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-caja',
  template: `
    <header>
      <ng-content select="[caja-titulo]">
        <h3>Sin título</h3>
      </ng-content>
    </header>
    <div class="cuerpo">
      <ng-content />
    </div>
    <footer>
      <ng-content select="[caja-pie], button" />
    </footer>
  `,
  styles: `
    :host {
      display: block;
      border: 1px solid #c5cae9;
      border-radius: 8px;
      overflow: hidden;
      background: #fff;
      margin-bottom: 0.75rem;
    }
    header { background: #e8eaf6; padding: 0.5rem 1rem; color: #1a237e; }
    header :is(h2, h3) { margin: 0; }
    .cuerpo { padding: 0.75rem 1rem; }
    footer { padding: 0 1rem 0.75rem; display: flex; gap: 0.5rem; }
  `,
})
export class Caja {}
// src/app/panel-cajas/panel-cajas.ts
@Component({
  selector: 'app-panel-cajas',
  imports: [Caja],
  template: `
    <app-caja>
      <h2 caja-titulo>Ana García López</h2>
      <p>Directora general desde 2010.</p>
      <p>Departamento de DIRECCIÓN, en Madrid.</p>
      <button type="button">Editar</button>
      <button type="button">Borrar</button>
    </app-caja>

    <app-caja>
      <p>Esta caja no trae título: se usa el contenido por defecto.</p>
    </app-caja>

    <app-caja>
      <ng-container ngProjectAs="[caja-titulo]">
        <h3>Títulos varios con ngProjectAs</h3>
        <small>Un contenedor cuenta como un solo bloque</small>
      </ng-container>
      <p>El contenedor no añade ningún elemento al DOM.</p>
      <small caja-pie>Actualizado hoy</small>
    </app-caja>
  `,
})
export class PanelCajas {}

Reparto de los nodos que escribe el padre entre las tres ranuras de la plantilla de Caja

Figura 8. Angular reparte los nodos que están en el primer nivel entre las ranuras. Los que no encajan en ninguna van a la ranura sin select.

Tres cajas: una con título, cuerpo y botones; otra con el título por defecto; y otra con un título de dos líneas y un texto al pie

Figura 9. Las tres cajas de PanelCajas, todas con el mismo componente.

Reglas de select:

Selector Recoge
select="[caja-titulo]" Elementos con ese atributo. Es la forma más usada
select="button" Elementos <button>
select="app-acciones" Instancias de ese componente
select=".destacado" Elementos con esa clase
select="[caja-pie], button" Cualquiera de los dos

El atributo caja-titulo no necesita declararse en ningún sitio: es un atributo más del HTML, que solo sirve de marca.

Cada nodo va a una sola ranura, y se prueban en orden de aparición. Los que no encajan con ningún select van a la que no lleva select. Si no hay ninguna ranura sin select, esos nodos desaparecen sin aviso.

Contenido por defecto

Lo que escribes dentro de <ng-content> se muestra si no llega nada a esa ranura. Es el <h3>Sin título</h3> de la segunda caja.

Solo se tiene en cuenta si no llega ningún nodo para esa ranura, y esto tiene una trampa con @if:

<app-caja>
  @if (conTitulo()) {
    <h2 caja-titulo>Con título</h2>
  }
  <p>Cuerpo</p>
</app-caja>

Con conTitulo() a false podrías esperar “Sin título”. Lo que ocurre es que la cabecera sale vacía. El bloque @if se proyecta a la ranura aunque no pinte nada en ese momento: la ranura está ocupada, y el contenido por defecto no se usa.

ngProjectAs

Solo cuentan los nodos del primer nivel. Si en la tercera caja hubieras envuelto el título en un <div caja-titulo>, valdría, pero añadirías un elemento extra al DOM. <ng-container> agrupa varios nodos sin generar ningún elemento, y ngProjectAs le dice a Angular: “para el reparto, trátame como si encajase con este selector”. Es la forma de encajar también en una ranura que pide algo que no puedes ofrecer directamente, como un select="button".

Esto también resuelve un aviso del compilador. Si un @if tiene varios nodos de raíz, no se proyecta a la ranura con nombre:

<app-caja>
  @if (v()) {
    <h2 caja-titulo>Uno</h2>
    <p>Dos</p>
  }
</app-caja>
▲ [WARNING] NG8011: Node matches the "[caja-titulo]" slot of the "Caja" component, but will not be
projected into the specific slot because the surrounding @if has more than one node at its root.

La solución es envolver el contenido del @if en un <ng-container ngProjectAs="[caja-titulo]">, de modo que tenga una sola raíz.

Dos errores de concepto

1. <ng-content> no duplica el contenido. Si colocas dos en la plantilla, no obtienes dos copias. El contenido se crea una sola vez, y acaba en el último:

<!-- Plantilla de app-doble -->
<div id="uno"><ng-content /></div>
<div id="dos"><ng-content /></div>

Con <app-doble><p>Hola</p></app-doble>, #uno queda vacío y #dos contiene Hola.

2. El contenido proyectado se crea siempre, se muestre o no. Es lo más sorprendente de la proyección, y conviene verlo con una prueba. Esta Sonda anota cuándo nace y cuándo muere:

@Component({
  selector: 'app-sonda',
  template: `<span>Sonda</span>`,
})
export class Sonda implements OnDestroy {
  static registro: string[] = [];

  constructor() {
    Sonda.registro.push('creada');
  }

  ngOnDestroy() {
    Sonda.registro.push('destruida');
  }
}

@Component({
  selector: 'app-desplegable',
  template: `
    <button type="button" (click)="abierto.update((a) => !a)">{{ titulo() }}</button>
    @if (abierto()) {
      <ng-content />
    }
  `,
})
export class Desplegable {
  readonly titulo = input.required<string>();
  readonly abierto = model(false);
}
<app-desplegable titulo="Proyección">
  <app-sonda />
</app-desplegable>

El panel está cerrado, y <app-sonda> no aparece en el DOM. Pero Sonda.registro ya vale ['creada']. Tras abrir, cerrar y abrir, sigue valiendo ['creada'].

El motivo es que quien crea ese contenido es el padre, al procesar su propia plantilla, antes de saber si el hijo lo va a mostrar. El @if del hijo solo decide si lo inserta en el DOM. Esto tiene consecuencias prácticas:

  • Un componente pesado proyectado se construye aunque nunca se vea.
  • Si hace una petición en su constructor, la petición sale igualmente.
  • Su estado sobrevive a cerrar y abrir, porque no se destruye.

Renderizado perezoso con ng-template

Cuando quieres que el contenido se cree solo si se muestra, el padre debe entregar una plantilla en vez de contenido ya creado. Recuerda del módulo 04 que <ng-template> define un trozo de plantilla que no se pinta hasta que alguien lo pide:

import { NgTemplateOutlet } from '@angular/common';
import { Component, contentChild, input, model, TemplateRef } from '@angular/core';

@Component({
  selector: 'app-desplegable-perezoso',
  imports: [NgTemplateOutlet],
  template: `
    <button type="button" (click)="abierto.update((a) => !a)">{{ titulo() }}</button>
    @if (abierto()) {
      <ng-container [ngTemplateOutlet]="plantilla()" />
    }
  `,
})
export class DesplegablePerezoso {
  readonly titulo = input.required<string>();
  readonly abierto = model(false);
  protected readonly plantilla = contentChild.required(TemplateRef);
}
<app-desplegable-perezoso titulo="Plantilla">
  <ng-template>
    <app-sonda />
  </ng-template>
</app-desplegable-perezoso>

contentChild.required(TemplateRef) localiza la plantilla (lo explica la siguiente sección), y ngTemplateOutlet la pinta donde se le dice. Ahora el comportamiento es el contrario:

Momento Con ng-content Con ng-template
Panel cerrado al inicio creada (nada)
Tras abrir, cerrar y abrir creada creada, destruida, creada
¿Conserva su estado al cerrar? Sí No
¿Se crea si nunca se abre? Sí No

9. contentChild y contentChildren

viewChild busca en la plantilla del propio componente. Si quieres mirar lo que te han proyectado, no lo encontrará, porque ese contenido está en la plantilla de otro. Para eso existen contentChild y contentChildren.

A la izquierda, el contenido escrito por el padre entre las etiquetas de app-pestanas. A la derecha, la vista del propio componente. Cada consulta busca en su zona

Figura 10. La vista es lo que el componente escribe en su plantilla. El contenido es lo que otro escribe entre sus etiquetas.

Se usan para crear componentes que trabajan en equipo. El ejemplo clásico son las pestañas: las usas colocando varias <app-pestana> dentro de <app-pestanas>, y el padre lee sus títulos para pintar la cabecera.

// src/app/pestanas/pestanas.ts
import { Component, contentChildren, effect, input, model, signal } from '@angular/core';

@Component({
  selector: 'app-pestana',
  template: `
    @if (activa()) {
      <section><ng-content /></section>
    }
  `,
})
export class Pestana {
  readonly titulo = input.required<string>();
  // La activa o desactiva el componente Pestanas
  readonly activa = signal(false);
}

@Component({
  selector: 'app-pestanas',
  template: `
    <div class="cabecera" role="tablist">
      @for (pestana of pestanas(); track pestana; let i = $index) {
        <button type="button" role="tab" [class.actual]="i === indice()"
          [attr.aria-selected]="i === indice()" (click)="indice.set(i)">
          {{ pestana.titulo() }}
        </button>
      }
    </div>
    <ng-content />
  `,
  styles: `
    .cabecera {
      display: flex;
      gap: 0.25rem;
      border-bottom: 2px solid #3f51b5;
      margin-bottom: 0.75rem;
    }
    button {
      border: 0;
      background: #e8eaf6;
      padding: 0.5rem 1rem;
      border-radius: 6px 6px 0 0;
      cursor: pointer;
    }
    .actual { background: #3f51b5; color: #fff; }
  `,
})
export class Pestanas {
  // Las pestañas que el padre ha colocado entre las etiquetas
  protected readonly pestanas = contentChildren(Pestana);
  readonly indice = model(0);

  constructor() {
    effect(() => {
      const actual = this.indice();
      this.pestanas().forEach((pestana, i) => pestana.activa.set(i === actual));
    });
  }
}
// src/app/ficha-pestanas/ficha-pestanas.ts
@Component({
  selector: 'app-ficha-pestanas',
  imports: [Pestanas, Pestana],
  template: `
    <h2>Ficha de Ana García López</h2>
    <app-pestanas>
      <app-pestana titulo="Datos"><p>Directora general, departamento de DIRECCIÓN.</p></app-pestana>
      <app-pestana titulo="Contacto"><p>ana.garcia&#64;empresa.es · 600 100 100</p></app-pestana>
      @if (verNomina()) {
        <app-pestana titulo="Nómina"><p>6500 € brutos al mes.</p></app-pestana>
      }
    </app-pestanas>
    <label>
      <input type="checkbox" [checked]="verNomina()" (change)="verNomina.update((v) => !v)" />
      Ver nómina
    </label>
  `,
})
export class FichaPestanas {
  protected readonly verNomina = signal(true);
}

Ficha con tres pestañas, Datos, Contacto y Nómina, con Contacto seleccionada

Figura 11. La cabecera se genera leyendo los títulos de las pestañas proyectadas.

Cómo funciona:

  1. contentChildren(Pestana) da la lista de las pestañas proyectadas, como señal.
  2. El @for de la cabecera lee pestana.titulo() de cada una. Aquí no hay problema de tiempos: las pestañas son contenido, y sus entradas se asignan antes de procesar la vista de Pestanas.
  3. El effect depende de indice y de la lista, y activa solo la que toca.
  4. Al desmarcar “Ver nómina”, el @if del padre retira esa pestaña, la señal de la consulta cambia y la cabecera pasa a tener dos botones sin que nadie la avise.

El &#64; de la plantilla es el carácter @ escrito como entidad HTML, necesario para que Angular no lo confunda con un bloque del módulo 04.

La opción descendants

Las dos funciones de contenido tienen distinto valor por defecto, y esto confunde. Montamos una prueba con una directiva Marca y un componente Recuento que la busca de todas las formas posibles:

<!-- Caso a -->
<app-recuento><span appMarca>1</span><div><span appMarca>2</span></div></app-recuento>

<!-- Caso b -->
<app-recuento><div><span appMarca>solo anidada</span></div></app-recuento>

<!-- Caso c: el último span está dentro de la plantilla de app-envoltorio-marca -->
<app-recuento>
  @for (n of [1, 2, 3]; track n) { <span appMarca>{{ n }}</span> }
  <app-envoltorio-marca />
</app-recuento>
Consulta a b c
contentChildren(Marca) (por defecto, descendants: false) 1 0 3
contentChildren(Marca, { descendants: true }) 2 1 3
contentChild(Marca) (por defecto, descendants: true) Sí Sí Sí
contentChild(Marca, { descendants: false }) Sí No Sí

De la tabla salen tres conclusiones:

  • contentChildren solo ve los hijos directos, salvo que pidas descendants: true. Si envuelves las pestañas en un <div>, la cabecera se queda vacía sin ningún error.
  • Los elementos generados por @for e @if cuentan como directos (caso c). Los bloques de control de flujo no añaden nivel.
  • Una consulta nunca entra en la plantilla de otro componente, ni con descendants: true. El span de app-envoltorio-marca no se cuenta (caso c). Eso es la encapsulación.

Igual que viewChild, estas consultas admiten una variable de plantilla y la opción read, y existe contentChild.required:

readonly encabezado = contentChild<ElementRef<HTMLElement>>('encabezado');
readonly plantilla = contentChild<TemplateRef<unknown>>('fila');

10. Contenedor y presentación

Ya tienes todas las piezas. Falta decidir cómo repartirlas en una pantalla real. El patrón más útil es separar los componentes en dos tipos:

  Contenedor (smart) Presentación (dumb)
Sabe de dónde vienen los datos Sí No
Guarda estado de la pantalla Sí Solo estado visual local
Se comunica con La API y sus hijos Su padre, por entradas y salidas
Tiene estilos elaborados Pocos Sí
Reutilizable en otras pantallas Poco Mucho
Fácil de probar Necesita datos falsos Muy fácil: entradas y salidas

Vamos a montar una pantalla de EMPRESA: un listado de empleados con filtro y detalle, contra la API de ejemplo. Arráncala con node server.mjs (descarga recursos/empresa-api.zip), y queda en http://localhost:3000/api.

Un contenedor conectado a la API y a tres componentes de presentación mediante entradas, salidas y un doble enlace

Figura 12. Toda la información pasa por el contenedor. Los tres hijos no se conocen entre sí.

Los componentes de presentación

// src/app/empleados/buscador-empleados.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-buscador-empleados',
  template: `
    <label>
      Filtrar:
      <input type="search" placeholder="nombre u oficio" [value]="texto()"
        (input)="texto.set($any($event.target).value)" />
    </label>
  `,
  styles: `input { margin-left: 0.5rem; padding: 0.3rem 0.5rem; }`,
})
export class BuscadorEmpleados {
  readonly texto = model('');
}
// src/app/empleados/tabla-empleados.ts
import { Component, input, output } from '@angular/core';
import { EmpleadoApi } from './empleado-api';

@Component({
  selector: 'app-tabla-empleados',
  template: `
    <table>
      <thead>
        <tr><th>Nombre</th><th>Oficio</th><th>Departamento</th></tr>
      </thead>
      <tbody>
        @for (e of empleados(); track e.empId) {
          <tr [class.elegido]="e.empId === elegidoId()" (click)="elegir.emit(e)">
            <td>{{ e.nombre }} {{ e.apellidos }}</td>
            <td>{{ e.oficio }}</td>
            <td>{{ e.departamento }}</td>
          </tr>
        } @empty {
          <tr><td colspan="3">Ningún empleado coincide.</td></tr>
        }
      </tbody>
    </table>
  `,
  styles: `
    table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
    th { text-align: left; background: #e8eaf6; color: #1a237e; }
    th, td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #eceff1; }
    tbody tr { cursor: pointer; }
    tbody tr:hover { background: #f5f5f5; }
    tbody tr.elegido { background: #ffccbc; }
  `,
})
export class TablaEmpleados {
  readonly empleados = input.required<EmpleadoApi[]>();
  readonly elegidoId = input<number | null>(null);
  readonly elegir = output<EmpleadoApi>();
}
// src/app/empleados/detalle-empleado.ts
import { Component, input, output } from '@angular/core';
import { EmpleadoApi } from './empleado-api';

@Component({
  selector: 'app-detalle-empleado',
  template: `
    <h3>{{ empleado().nombre }} {{ empleado().apellidos }}</h3>
    <dl>
      <dt>Correo</dt><dd>{{ empleado().email }}</dd>
      <dt>Oficio</dt><dd>{{ empleado().oficio }}</dd>
      <dt>Salario</dt><dd>{{ empleado().salario }} €</dd>
    </dl>
    <button type="button" (click)="cerrar.emit()">Cerrar</button>
  `,
  styles: `
    :host {
      display: block;
      padding: 0.75rem 1rem;
      border: 1px solid #c5cae9;
      border-radius: 8px;
      background: #fff;
    }
    h3 { margin: 0 0 0.5rem; color: #1a237e; }
    dl { margin: 0 0 0.75rem; font-size: 0.9rem; }
    dt { font-weight: 600; }
    dd { margin: 0 0 0.35rem; overflow-wrap: anywhere; }
  `,
})
export class DetalleEmpleado {
  readonly empleado = input.required<EmpleadoApi>();
  readonly cerrar = output();
}

Ninguno sabe qué es la API. TablaEmpleados no filtra ni decide qué fila está elegida: recibe la lista y el identificador, y avisa de los clics.

El contenedor

// src/app/empleados/empleado-api.ts
export interface EmpleadoApi {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  oficio: string;
  salario: number;
  departamento: string;
}
// src/app/empleados/pagina-empleados.ts
import { Component, computed, signal } from '@angular/core';
import { BuscadorEmpleados } from './buscador-empleados';
import { DetalleEmpleado } from './detalle-empleado';
import { EmpleadoApi } from './empleado-api';
import { TablaEmpleados } from './tabla-empleados';

const API = 'http://localhost:3000/api';

@Component({
  selector: 'app-pagina-empleados',
  imports: [BuscadorEmpleados, TablaEmpleados, DetalleEmpleado],
  template: `
    <h2>Empleados</h2>
    @if (error(); as mensaje) {
      <p class="error">{{ mensaje }}</p>
    } @else if (cargando()) {
      <p>Cargando...</p>
    } @else {
      <app-buscador-empleados [(texto)]="filtro" />
      <p class="recuento">{{ visibles().length }} de {{ empleados().length }} empleados</p>
      <div class="rejilla">
        <app-tabla-empleados
          [empleados]="visibles()"
          [elegidoId]="elegido()?.empId ?? null"
          (elegir)="elegido.set($event)"
        />
        @if (elegido(); as empleado) {
          <app-detalle-empleado [empleado]="empleado" (cerrar)="elegido.set(null)" />
        }
      </div>
    }
  `,
  styles: `
    h2 { color: #1a237e; margin: 0 0 0.5rem; }
    .rejilla { display: grid; grid-template-columns: 1fr 15rem; gap: 1rem; align-items: start; }
    .recuento { color: #546e7a; font-size: 0.9rem; }
    .error { color: #c62828; }
  `,
})
export class PaginaEmpleados {
  protected readonly empleados = signal<EmpleadoApi[]>([]);
  protected readonly cargando = signal(true);
  protected readonly error = signal<string | null>(null);
  protected readonly filtro = signal('');
  protected readonly elegido = signal<EmpleadoApi | null>(null);

  protected readonly visibles = computed(() => {
    const texto = this.filtro().trim().toLowerCase();
    return this.empleados().filter((e) =>
      `${e.nombre} ${e.apellidos} ${e.oficio}`.toLowerCase().includes(texto),
    );
  });

  constructor() {
    void this.cargar();
  }

  private async cargar() {
    try {
      const respuesta = await fetch(`${API}/empleados?tamano=100`);
      if (!respuesta.ok) {
        throw new Error(`Error ${respuesta.status}`);
      }
      const pagina = await respuesta.json();
      this.empleados.set(pagina.contenido);
    } catch (e) {
      this.error.set(`No se pudo cargar la lista: ${(e as Error).message}`);
    } finally {
      this.cargando.set(false);
    }
  }
}

Listado de empleados filtrado por director, con la fila de Luis Martínez resaltada y su detalle a la derecha

Figura 13. Datos reales de la API, con el filtro director: 6 de 24 empleados, y Luis seleccionado.

💡 Consejo: Usamos fetch directamente para no adelantar temario. En una aplicación real, los datos se piden con HttpClient (módulo 12) o con httpResource (módulo 13), normalmente desde un servicio (módulo 08). Lo que cambia es el interior de cargar(): los tres componentes de presentación no se enteran.

Comunicación entre hermanos

Fíjate en cómo se entera DetalleEmpleado de qué fila se ha pulsado en TablaEmpleados. Son hermanos: ninguno tiene referencia al otro.

  1. El usuario pulsa una fila, y TablaEmpleados emite elegir.
  2. El contenedor hace elegido.set($event).
  3. La señal elegido baja a DetalleEmpleado y, como elegidoId, a la tabla.

Esto se llama subir el estado (lifting state up): cuando dos hermanos necesitan un dato, ese dato se guarda en su padre común. Pulsar Cerrar recorre el mismo camino: DetalleEmpleado emite cerrar, el contenedor pone elegido a null y el @if lo retira. El filtro también: el buscador escribe en la señal filtro del contenedor, visibles se recalcula y la tabla recibe la lista nueva.

Lo que no debes hacer es conectar los hermanos entre sí con viewChild o #referencias. Si la tabla dependiera del detalle, ya no se podría reutilizar por separado.

📘 Concepto: Pregunta siempre “¿quién es dueño de este dato?”. La respuesta es el componente más bajo del árbol que contenga a todos los que lo usan. Si esa respuesta está a cuatro niveles de distancia, es el momento de usar un servicio (módulo 19).


11. Qué vía elegir

Pregunta Respuesta
¿Es un dato que el hijo muestra? input()
¿Es un dato sin el que el hijo no funciona? input.required()
¿Quieres escribirlo como atributo HTML? input() con transform
¿Es algo que ocurre en el hijo? output()
¿El componente edita un valor del padre? model()
¿Es una orden imperativa, o necesitas tocar el DOM? viewChild()
¿Quieres repetir una acción sobre varios hijos? viewChildren()
¿Lo que varía es el HTML? ng-content
¿Ese HTML debe crearse solo al mostrarse? ng-template + ngTemplateOutlet
¿Necesitas conocer a los hijos proyectados? contentChild(), contentChildren()
¿Se cruzan más de dos o tres niveles? Un servicio

Si vienes de código más antiguo, esto es lo mismo que hacían los decoradores, que aún verás mucho:

Clásico Actual
@Input() nombre!: string nombre = input.required<string>()
@Input({ transform: booleanAttribute }) input(false, { transform: booleanAttribute })
@Output() guardar = new EventEmitter<T>() guardar = output<T>()
@Input() + @Output() xChange x = model()
@ViewChild(...) + ngAfterViewInit viewChild() + computed o effect
@ContentChildren(...) + QueryList contentChildren()

La migración de ese código se trata en el módulo 28.


12. Errores frecuentes y depuración

Todos estos mensajes son reales, obtenidos al compilar o ejecutar cada caso.

Lo que ves Causa Solución
NG8008: Required input 'x' from component Y must be specified Falta una entrada obligatoria Pásala en la plantilla
NG8002: Can't bind to 'x' since it isn't a known property of 'app-y' Esa entrada no existe, o el nombre está mal escrito Revisa el nombre, y que el componente esté en imports
TS2322: Type 'string' is not assignable to type 'boolean' Atributo sin corchetes sobre una entrada sin transformación Usa [x]="true", o añade transform: booleanAttribute
TS2339: Property 'set' does not exist on type 'InputSignal<string>' Escribir en una entrada Usa model(), linkedSignal o una salida
NG8118: `a` is a required `input` and does not have a value in this context Lectura en el constructor o en un inicializador Mueve la lectura a computed o effect
NG0950: Input "a" is required but no value is available yet Igual, oculta en una función, o entrada de un hijo leída demasiado pronto Igual
NG0951: Child query result is required but no value is available Consulta required sin resultado, o leída antes de tiempo Quita required si el elemento es opcional
NG8007: The property and event halves of the two-way binding 'x' are not bound to the same target [(x)] sobre un input() Declara x = model() en el hijo
NG5002: Unsupported expression in a two-way binding [(x)]="senal()" Quita los paréntesis
NG8011: Node matches the "[x]" slot... (aviso) @if con varias raíces dentro de una ranura Envuelve con ng-container ngProjectAs
NaN en la pantalla numberAttribute con un texto que no es número Indica un valor de reserva
Un manejador (x) nunca se ejecuta, sin error Nombre mal escrito, o salida de un nieto Revisa el nombre; las salidas no burbujean
Un manejador se ejecuta dos veces La salida se llama como un evento nativo Renómbrala
La cabecera sale vacía en lugar de usar el contenido por defecto @if dentro de una ranura Pasa el dato como entrada
El contenido proyectado desaparece sin error Su nodo no encaja en ninguna ranura Añade un <ng-content /> sin select
El hijo no refleja un cambio en un objeto Mutación: la referencia no cambia Crea un objeto nuevo

Para depurar:

  1. Empieza por el dueño del dato. Si la tabla muestra algo mal, mira primero qué señal del contenedor la alimenta.
  2. Usa Angular DevTools, que viste en el módulo 01. Selecciona el componente y comprueba sus entradas y salidas.
  3. Comprueba la interfaz. Un olvido habitual son los paréntesis en el padre: [nombre]="nombre" pasa la señal entera, y el compilador responde TS2322: Type 'WritableSignal<string>' is not assignable to type 'string'.
  4. Pon un effect temporal: effect(() => console.log(this.empleado())) te muestra cada valor recibido.

13. Ejercicios

✏️ Ejercicio: Cada solución de este apartado se ha compilado y ejecutado. Intenta resolverlos antes de abrirlas.

Ejercicio 1: Insignia de estado

Crea InsigniaEstado, que muestra el estado de un pedido como una etiqueta de color.

  • Entrada obligatoria estado. Debe aceptar string, null o undefined, recortar espacios y pasar a mayúsculas. Si no hay valor, muestra DESCONOCIDO.
  • Entrada compacta, que se pueda escribir como atributo sin valor.
  • La clase CSS se deriva del estado (entregado, cancelado, enviado).
Ver solución
import { booleanAttribute, Component, computed, input } from '@angular/core';

function normalizarEstado(valor: string | null | undefined): string {
  return (valor ?? 'DESCONOCIDO').trim().toUpperCase();
}

@Component({
  selector: 'app-insignia-estado',
  template: `<span [class]="clase()">{{ estado() }}</span>`,
  styles: `
    span {
      padding: 0.15rem 0.6rem;
      border-radius: 999px;
      font-weight: 600;
      background: #eceff1;
      color: #37474f;
    }
    .compacta { font-size: 0.7rem; }
    .entregado { background: #c8e6c9; color: #1b5e20; }
    .cancelado { background: #ffcdd2; color: #b71c1c; }
    .enviado { background: #c5cae9; color: #1a237e; }
  `,
})
export class InsigniaEstado {
  readonly estado = input.required({ transform: normalizarEstado });
  readonly compacta = input(false, { transform: booleanAttribute });

  protected readonly clase = computed(() => {
    const color = this.estado().toLowerCase();
    return this.compacta() ? `${color} compacta` : color;
  });
}
<app-insignia-estado estado=" entregado " compacta />
<app-insignia-estado [estado]="sinDato" />

La primera muestra ENTREGADO con las clases entregado y compacta. La segunda, con sinDato = null, muestra DESCONOCIDO.

Como normalizarEstado lleva sus tipos anotados, TypeScript deduce los dos tipos de la entrada, y no hace falta escribir input.required<string, string | null | undefined>. La transformación solo normaliza el valor. La clase CSS depende además de otra entrada, y por eso es un computed.

Ejercicio 2: Botón con confirmación

Crea BotonBorrar. Al pulsarlo muestra “¿Seguro?” con dos botones, “Sí, borrar” y “Cancelar”. Solo al confirmar emite la salida confirmado, sin datos. El texto inicial es una entrada con valor por defecto Borrar.

Ver solución
import { Component, input, output, signal } from '@angular/core';

@Component({
  selector: 'app-boton-borrar',
  template: `
    @if (pidiendoConfirmacion()) {
      <span>¿Seguro?</span>
      <button type="button" (click)="confirmar()">Sí, borrar</button>
      <button type="button" (click)="pidiendoConfirmacion.set(false)">Cancelar</button>
    } @else {
      <button type="button" (click)="pidiendoConfirmacion.set(true)">{{ texto() }}</button>
    }
  `,
})
export class BotonBorrar {
  readonly texto = input('Borrar');
  readonly confirmado = output();
  protected readonly pidiendoConfirmacion = signal(false);

  protected confirmar() {
    this.pidiendoConfirmacion.set(false);
    this.confirmado.emit();
  }
}
<app-boton-borrar texto="Borrar empleado" (confirmado)="borrar(empleado.empId)" />

En la prueba, al cancelar no se emite nada, y al confirmar se emite una sola vez y el botón vuelve a su estado inicial.

pidiendoConfirmacion es estado visual, del propio botón, y por eso es una señal local y protected. Al padre solo le interesa el resultado final.

Ejercicio 3: Paginador

Crea Paginador con:

  • pagina: enlace doble, con valor inicial 1.
  • total: entrada obligatoria, número de elementos.
  • tamano: elementos por página, con valor por defecto 10, que se pueda escribir como texto (tamano="5").

Muestra “Página X de Y” y dos botones que se desactivan en los extremos.

Ver solución
import { Component, computed, input, model, numberAttribute } from '@angular/core';

@Component({
  selector: 'app-paginador',
  template: `
    <nav aria-label="Paginación">
      <button type="button" [disabled]="pagina() === 1" (click)="ir(pagina() - 1)">Anterior</button>
      <span>Página {{ pagina() }} de {{ paginas() }}</span>
      <button type="button" [disabled]="pagina() === paginas()" (click)="ir(pagina() + 1)">Siguiente</button>
    </nav>
  `,
  styles: `nav { display: flex; gap: 0.75rem; align-items: center; }`,
})
export class Paginador {
  readonly pagina = model(1);
  readonly total = input.required<number>();
  readonly tamano = input(10, { transform: numberAttribute });

  protected readonly paginas = computed(() => Math.max(1, Math.ceil(this.total() / this.tamano())));

  protected ir(destino: number) {
    this.pagina.set(destino);
  }
}
<app-paginador [(pagina)]="pagina" [total]="24" tamano="5" />

Con 24 elementos de 5 en 5 salen 5 páginas. En la prueba, tras dos clics en Siguiente, se ve Página 3 de 5 y la señal del padre vale 3. Si el padre hace pagina.set(5), el botón Siguiente queda desactivado.

Math.max(1, ...) evita “Página 1 de 0” con una lista vacía.

Ejercicio 4: Interruptor

Crea Interruptor, un botón con role="switch". Tiene un activo con enlace doble, una etiqueta obligatoria y una entrada desactivado que se pueda escribir sin valor. Debe poder usarse en un panel con [(activo)] y también con [activo]="false" desactivado.

Ver solución
import { booleanAttribute, Component, input, model, signal } from '@angular/core';

@Component({
  selector: 'app-interruptor',
  template: `
    <button type="button" role="switch" [attr.aria-checked]="activo()" [disabled]="desactivado()"
      (click)="alternar()">
      <span class="pomo"></span>
    </button>
    <span class="texto">{{ etiqueta() }}: {{ activo() ? 'sí' : 'no' }}</span>
  `,
  styles: `
    :host { display: inline-flex; align-items: center; gap: 0.5rem; }
    button {
      width: 2.75rem;
      height: 1.5rem;
      border: 0;
      border-radius: 999px;
      background: #cfd8dc;
      padding: 0.15rem;
      cursor: pointer;
      display: flex;
    }
    button[aria-checked='true'] { background: #3f51b5; justify-content: flex-end; }
    .pomo { width: 1.2rem; height: 1.2rem; border-radius: 50%; background: #fff; }
  `,
})
export class Interruptor {
  readonly activo = model(false);
  readonly etiqueta = input.required<string>();
  readonly desactivado = input(false, { transform: booleanAttribute });

  protected alternar() {
    this.activo.update((a) => !a);
  }
}

@Component({
  selector: 'app-ajustes',
  imports: [Interruptor],
  template: `
    <app-interruptor etiqueta="Avisos por correo" [(activo)]="correo" /><br />
    <app-interruptor etiqueta="Modo administrador" [activo]="false" desactivado />
    <p>{{ correo() ? 'Recibirás avisos' : 'Sin avisos' }}</p>
  `,
})
export class Ajustes {
  protected readonly correo = signal(true);
}

Pulsar el primero cambia a Sin avisos. El segundo no reacciona, porque un button con disabled no dispara clics.

activo no puede llevar booleanAttribute, porque model() no admite transform. Por eso se escribe [activo]="false" y no activo="false".

Ejercicio 5: Caja de comentario

Crea CajaComentario, oculta al principio. Ofrece un método público abrir() que muestra un <textarea> y le pone el foco. El texto es un enlace doble, y maximo (por defecto 140) limita la longitud y alimenta un contador de caracteres restantes. Un padre la abre desde un botón situado fuera.

Ver solución
import { Component, computed, effect, ElementRef, input, model, signal, viewChild } from '@angular/core';

@Component({
  selector: 'app-caja-comentario',
  template: `
    @if (abierta()) {
      <textarea #campo rows="3" [maxLength]="maximo()" [value]="texto()"
        (input)="texto.set($any($event.target).value)"></textarea>
      <p>{{ restantes() }} caracteres restantes</p>
    }
  `,
})
export class CajaComentario {
  readonly texto = model('');
  readonly maximo = input(140);
  protected readonly abierta = signal(false);
  protected readonly restantes = computed(() => this.maximo() - this.texto().length);
  private readonly campo = viewChild<ElementRef<HTMLTextAreaElement>>('campo');

  constructor() {
    effect(() => {
      this.campo()?.nativeElement.focus();
    });
  }

  abrir() {
    this.abierta.set(true);
  }
}

@Component({
  selector: 'app-tarea-comentada',
  imports: [CajaComentario],
  template: `
    <button type="button" (click)="caja.abrir()">Comentar</button>
    <app-caja-comentario #caja [(texto)]="comentario" [maximo]="20" />
    <p>Guardado: {{ comentario() }}</p>
  `,
})
export class TareaComentada {
  protected readonly comentario = signal('');
}

Tras escribir Hola, la prueba muestra 16 caracteres restantes y Guardado: Hola, y document.activeElement es el TEXTAREA.

Llamar a focus() dentro de abrir() no funcionaría: en ese momento el @if aún no ha creado el elemento. Por eso el foco se pone en un effect que depende de la consulta. Aquí encajan los dos estilos: abrir es una orden, y va por método público; el texto es un dato, y va por model().

Ejercicio 6: Diálogo modal

Crea Modal sobre el elemento nativo <dialog>:

  • abierto: enlace doble.
  • Ranuras: título (con contenido por defecto “Aviso”), cuerpo y acciones, además de un botón Cerrar propio.
  • Cuando abierto cambie, abre o cierra el diálogo con showModal() y close().
  • Si el usuario cierra con Escape, abierto debe volver a false.
Ver solución
import { afterRenderEffect, Component, ElementRef, model, signal, viewChild } from '@angular/core';

@Component({
  selector: 'app-modal',
  template: `
    <dialog #dialogo (close)="abierto.set(false)">
      <header><ng-content select="[modal-titulo]"><h3>Aviso</h3></ng-content></header>
      <div class="cuerpo"><ng-content /></div>
      <footer>
        <ng-content select="[modal-acciones]" />
        <button type="button" (click)="abierto.set(false)">Cerrar</button>
      </footer>
    </dialog>
  `,
  styles: `
    dialog { border: 1px solid #c5cae9; border-radius: 8px; padding: 0; width: 22rem; }
    dialog::backdrop { background: rgb(26 35 126 / 0.35); }
    header { background: #e8eaf6; color: #1a237e; padding: 0.5rem 1rem; }
    header :is(h2, h3) { margin: 0; }
    .cuerpo { padding: 0.75rem 1rem; }
    footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0 1rem 0.75rem; }
  `,
})
export class Modal {
  readonly abierto = model(false);
  private readonly dialogo = viewChild.required<ElementRef<HTMLDialogElement>>('dialogo');

  constructor() {
    afterRenderEffect(() => {
      const elemento = this.dialogo().nativeElement;
      if (this.abierto() && !elemento.open) {
        elemento.showModal();
      } else if (!this.abierto() && elemento.open) {
        elemento.close();
      }
    });
  }
}

@Component({
  selector: 'app-demo-modal',
  imports: [Modal],
  template: `
    <button type="button" (click)="borrando.set(true)">Borrar empleado 124</button>
    <app-modal [(abierto)]="borrando">
      <h3 modal-titulo>Borrar empleado</h3>
      <p>Esta acción no se puede deshacer.</p>
      <button type="button" modal-acciones (click)="borrar()">Borrar</button>
    </app-modal>
    <p>{{ resultado() }}</p>
  `,
})
export class DemoModal {
  protected readonly borrando = signal(false);
  protected readonly resultado = signal('Sin cambios');

  protected borrar() {
    this.resultado.set('Empleado 124 borrado');
    this.borrando.set(false);
  }
}

Diálogo modal titulado Borrar empleado, con fondo oscurecido y los botones Borrar y Cerrar

Figura 14. El diálogo nativo abierto con showModal(). Las tres ranuras las rellena el padre.

Comprobado en Chromium: el diálogo se abre; con Escape, open pasa a false y se puede volver a abrir; y al pulsar Borrar se cierra y aparece Empleado 124 borrado.

La clave es que hay un solo dato, abierto, y dos direcciones. afterRenderEffect (módulo 03) lleva la señal al DOM después de pintar. El evento close lleva el DOM a la señal cuando el navegador cierra por su cuenta con Escape. Sin ese (close), abierto seguiría en true, y al volver a pulsar el botón del padre no pasaría nada, porque set(true) sobre true no notifica. Las comprobaciones de elemento.open evitan llamar a showModal() o close() cuando el diálogo ya está en ese estado.

Ejercicio 7: Acordeón

Crea Acordeon y Seccion. Cada Seccion tiene un titulo y un estado abierta, y proyecta su contenido. Acordeon contiene varias secciones y garantiza que solo haya una abierta a la vez, salvo que lleve el atributo multiple.

Ver solución
import { booleanAttribute, Component, contentChildren, effect, input, model } from '@angular/core';

@Component({
  selector: 'app-seccion',
  template: `
    <button type="button" [attr.aria-expanded]="abierta()" (click)="abierta.update((a) => !a)">
      {{ abierta() ? '▾' : '▸' }} {{ titulo() }}
    </button>
    @if (abierta()) {
      <div class="contenido"><ng-content /></div>
    }
  `,
  styles: `
    :host { display: block; border-bottom: 1px solid #c5cae9; }
    button {
      width: 100%;
      text-align: left;
      border: 0;
      background: #e8eaf6;
      color: #1a237e;
      padding: 0.5rem 0.75rem;
      font-weight: 600;
      cursor: pointer;
    }
    .contenido { padding: 0.5rem 0.75rem; }
  `,
})
export class Seccion {
  readonly titulo = input.required<string>();
  readonly abierta = model(false);
}

@Component({
  selector: 'app-acordeon',
  template: `<ng-content />`,
  styles: `:host { display: block; border: 1px solid #c5cae9; border-radius: 8px; overflow: hidden; }`,
})
export class Acordeon {
  readonly multiple = input(false, { transform: booleanAttribute });
  private readonly secciones = contentChildren(Seccion);

  constructor() {
    effect((alLimpiar) => {
      const secciones = this.secciones();
      const suscripciones = secciones.map((seccion) =>
        seccion.abierta.subscribe((abierta) => {
          if (abierta && !this.multiple()) {
            secciones.filter((otra) => otra !== seccion).forEach((otra) => otra.abierta.set(false));
          }
        }),
      );
      alLimpiar(() => suscripciones.forEach((s) => s.unsubscribe()));
    });
  }
}

@Component({
  selector: 'app-preguntas',
  imports: [Acordeon, Seccion],
  template: `
    <h2>Preguntas frecuentes</h2>
    <app-acordeon>
      <app-seccion titulo="¿Cómo pido vacaciones?" [abierta]="true">
        <p>Desde el portal del empleado.</p>
      </app-seccion>
      <app-seccion titulo="¿Cuándo se cobra la nómina?">
        <p>El último día laborable del mes.</p>
      </app-seccion>
      <app-seccion titulo="¿Quién aprueba los gastos?">
        <p>Tu responsable directo.</p>
      </app-seccion>
    </app-acordeon>
  `,
})
export class Preguntas {}

Acordeón con tres preguntas y la segunda desplegada

Figura 15. Al abrir la segunda sección, la primera se ha cerrado sola.

Un ModelSignal también tiene subscribe: funciona como una salida, y avisa cuando escribe alguien desde el componente, no cuando llega un valor nuevo del padre. Las secciones pueden cambiar (por un @if o un @for), así que las suscripciones se crean dentro de un effect y se liberan en su función de limpieza. Sin esa limpieza, cada cambio en la lista acumularía suscripciones duplicadas.

Las escrituras otra.abierta.set(false) también disparan subscribe, con false, y por eso no hay bucle: la condición abierta && ... no se cumple.

Ejercicio 8: Pantalla de pedidos

Con la API de EMPRESA, monta una pantalla de pedidos con el patrón contenedor y presentación:

  • PaginaPedidos (contenedor) pide /pedidos?tamano=100.
  • FiltroEstado muestra un botón por estado más TODOS, con model.required.
  • ResumenPedidos muestra el número de pedidos y el importe total.
  • ListaPedidos pinta cada pedido, y reutiliza la InsigniaEstado del ejercicio 1.

Los estados disponibles se deben deducir de los datos, sin escribirlos a mano.

Ver solución
import { Component, computed, input, model, signal } from '@angular/core';
import { InsigniaEstado } from './insignia-estado';

const API = 'http://localhost:3000/api';

export interface Pedido {
  pedidoId: number;
  fechaPedido: string;
  estado: string;
  cliente: string;
  total: number;
}

@Component({
  selector: 'app-filtro-estado',
  template: `
    @for (opcion of opciones(); track opcion) {
      <button type="button" [class.actual]="opcion === estado()" (click)="estado.set(opcion)">
        {{ opcion }}
      </button>
    }
  `,
  styles: `
    button {
      margin-right: 0.25rem;
      border: 1px solid #c5cae9;
      background: #fff;
      padding: 0.25rem 0.6rem;
      border-radius: 999px;
      cursor: pointer;
      font-size: 0.8rem;
    }
    .actual { background: #3f51b5; color: #fff; }
  `,
})
export class FiltroEstado {
  readonly opciones = input.required<string[]>();
  readonly estado = model.required<string>();
}

@Component({
  selector: 'app-resumen-pedidos',
  template: `<p>{{ pedidos().length }} pedidos por un total de {{ total() }} €</p>`,
  styles: `p { font-weight: 600; color: #1a237e; }`,
})
export class ResumenPedidos {
  readonly pedidos = input.required<Pedido[]>();
  protected readonly total = computed(() =>
    Math.round(this.pedidos().reduce((suma, p) => suma + p.total, 0) * 100) / 100,
  );
}

@Component({
  selector: 'app-lista-pedidos',
  imports: [InsigniaEstado],
  template: `
    <ul>
      @for (p of pedidos(); track p.pedidoId) {
        <li>
          <span>{{ p.pedidoId }}</span>
          <span class="cliente">{{ p.cliente }}</span>
          <span>{{ p.total }} €</span>
          <app-insignia-estado [estado]="p.estado" compacta />
        </li>
      }
    </ul>
  `,
  styles: `
    ul { list-style: none; margin: 0; padding: 0; }
    li {
      display: grid;
      grid-template-columns: 4rem 1fr 6rem 7rem;
      gap: 0.5rem;
      padding: 0.3rem 0;
      border-bottom: 1px solid #eceff1;
      font-size: 0.9rem;
    }
    .cliente { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  `,
})
export class ListaPedidos {
  readonly pedidos = input.required<Pedido[]>();
}

@Component({
  selector: 'app-pagina-pedidos',
  imports: [FiltroEstado, ResumenPedidos, ListaPedidos],
  template: `
    <h2>Pedidos</h2>
    <app-filtro-estado [opciones]="estados()" [(estado)]="estado" />
    <app-resumen-pedidos [pedidos]="visibles()" />
    <app-lista-pedidos [pedidos]="visibles()" />
  `,
})
export class PaginaPedidos {
  private readonly pedidos = signal<Pedido[]>([]);
  protected readonly estado = signal('TODOS');

  protected readonly estados = computed(() => [
    'TODOS',
    ...new Set(this.pedidos().map((p) => p.estado)),
  ]);
  protected readonly visibles = computed(() => {
    const estado = this.estado();
    return estado === 'TODOS' ? this.pedidos() : this.pedidos().filter((p) => p.estado === estado);
  });

  constructor() {
    void this.cargar();
  }

  private async cargar() {
    const respuesta = await fetch(`${API}/pedidos?tamano=100`);
    const pagina = await respuesta.json();
    this.pedidos.set(pagina.contenido);
  }
}

Pantalla de pedidos filtrada por ENTREGADO, con 18 pedidos por un total de 54076,97 euros

Figura 16. Con datos reales. La API devuelve cuatro estados: ENTREGADO, CANCELADO, ENVIADO y PENDIENTE.

estados y visibles son computed, así que no hay nada que sincronizar. Cuando llegan los pedidos, los botones aparecen solos. ResumenPedidos y ListaPedidos son hermanos que comparten datos sin conocerse.

Math.round(x * 100) / 100 elimina los decimales espurios de sumar números en coma flotante, que explica el Curso de JavaScript. Esta solución omite los estados de carga y error para abreviar: añádelos como en PaginaEmpleados.

Ejercicio 9 (propuesto): Selector de departamento

Crea SelectorDepartamento, un <select> con los departamentos de /api/departamentos, y un deptId = model<number | null>(null). Después, en un contenedor, muestra los empleados de /api/departamentos/:id/empleados cada vez que cambie. Ten cuidado con el valor del <select>, que siempre es una cadena.

Ejercicio 10 (propuesto): Tabla con celdas a medida

Crea TablaGenerica<T>, que reciba filas = input.required<T[]>() y una plantilla de fila proyectada con <ng-template let-fila>. Localízala con contentChild.required(TemplateRef), y píntala con [ngTemplateOutlet] y [ngTemplateOutletContext]="{ $implicit: fila }". Úsala para mostrar productos y clientes con columnas distintas.

Ejercicio 11 (propuesto): Pestañas seguras

La Pestanas de la sección 9 tiene un fallo: si estás en la última pestaña y el padre la quita, no queda ninguna activa. Corrígelo para que indice nunca apunte fuera de la lista. Añade también navegación con las flechas izquierda y derecha, y deja la pestaña activa con foco, usando viewChildren sobre los botones.


Resumen

Concepto API Idea clave
Regla general   Los datos bajan y los eventos suben
Entrada input(valorInicial) Señal de solo lectura; se lee con ()
Entrada obligatoria input.required<T>() NG8008 si falta; sin valor en el constructor
Transformación { transform }, booleanAttribute, numberAttribute Función pura; ojo con NaN
Copia editable linkedSignal(() => entrada()) Borrador que se reinicia con cada dato nuevo
Objetos update((e) => ({ ...e })) No mutes: 🔄 v22.0 con OnPush el hijo no lo ve
Salida output<T>(), emit(valor) $event es lo emitido; no burbujea
Aviso sin datos output(), emit() Sin prefijo on ni nombres nativos
Enlace doble model(), [(x)]="senal" Entrada x + salida xChange; sin paréntesis
Consulta de la vista viewChild, viewChildren Señales; read elige qué devolver
Variable de plantilla #reloj Basta si solo la usas en la plantilla
Proyección <ng-content /> El contenido sigue siendo del padre
Ranuras select, ngProjectAs Lo que no encaja va a la ranura sin select
Contenido por defecto <ng-content>...</ng-content> Un @if ocupa la ranura aunque no pinte
Creación perezosa ng-template, ngTemplateOutlet Lo proyectado se crea siempre; la plantilla, no
Consulta de contenido contentChild, contentChildren descendants tiene distinto valor por defecto en cada una
Contenedor y presentación   El estado arriba, y el resto solo entradas y salidas
Hermanos Subir el estado Se comunican a través de su padre común