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()yinput.required(), y derivar valores de ellas concomputed - Convertir lo que llega por una entrada con
transform,booleanAttributeynumberAttribute - 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
viewChildyviewChildren - Recibir HTML del padre con
ng-content, varias ranuras,select, contenido por defecto yngProjectAs - Saber cuándo la proyección no basta y hace falta
ng-templateconngTemplateOutlet - Inspeccionar el contenido proyectado con
contentChildycontentChildren - 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:
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);
}
}

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 entradaempleadodel hijo.- El hijo lee la entrada llamándola:
empleado(). Es una señal, así queempleadosin 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 llevanprotected. - El padre tiene que importar al hijo en
importspara 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
effectsolo para copiar una entrada en una señal, para. Eso es uncomputed(si es solo de lectura) o unlinkedSignal(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 concomputed.
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);
}
}

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
numberAttributelo deja igual. Las transformaciones se aplican a todo valor que llegue, no solo a los literales. - Tercera fila:
valor="abc"daNaN. El cálculo del ancho daNaN, 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
changeDetectionse 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ñadechangeDetection: ChangeDetectionStrategy.OnPusha 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 }));
}
}

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:
- El clic ocurre en una plantilla de la madre, de modo que Angular marca esa vista para revisarla.
- Angular repinta la madre. Su
{{ empleado().salario }}lee el objeto mutado y muestra 5000. - 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. - La señal
empleadono ha notificado nada, porque nadie llamó asetni aupdate. 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));
}
}

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 unProducto.output()sin tipo se usa para un aviso sin datos, y se dispara conemit()sin argumentos.anadir.emit(producto())entrega el valor a quien esté escuchando.(anadir)="alAnadir($event)"escucha.$eventes lo que se emitió, no unEventdel DOM. Aquí es unProducto.(descartar)="alDescartar(producto.productoId)"no necesita$event, porque dentro del@forel 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);
}

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.
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 admitetransform. Sus opciones son soloaliasydebugName, 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. Uninput()lo garantiza.
💡 Consejo: El
[(ngModel)]de los formularios usa exactamente el mismo mecanismo, una entradangModely una salidangModelChange. 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.requiredsignifica que siempre estará, de modo que el tipo esCronometroy noCronometro | undefined.#relojes 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 conNG0950: Input "empleado" is required but no value is available yet. El padre evalúa su plantilla antes de que el@forhaya 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 {}
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.

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.
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@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);
}

Figura 11. La cabecera se genera leyendo los títulos de las pestañas proyectadas.
Cómo funciona:
contentChildren(Pestana)da la lista de las pestañas proyectadas, como señal.- El
@forde la cabecera leepestana.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 dePestanas. - El
effectdepende deindicey de la lista, y activa solo la que toca. - Al desmarcar “Ver nómina”, el
@ifdel 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 @ 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:
contentChildrensolo ve los hijos directos, salvo que pidasdescendants: true. Si envuelves las pestañas en un<div>, la cabecera se queda vacía sin ningún error.- Los elementos generados por
@fore@ifcuentan 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. Elspandeapp-envoltorio-marcano 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.
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);
}
}
}

Figura 13. Datos reales de la API, con el filtro director: 6 de 24 empleados, y Luis seleccionado.
💡 Consejo: Usamos
fetchdirectamente para no adelantar temario. En una aplicación real, los datos se piden conHttpClient(módulo 12) o conhttpResource(módulo 13), normalmente desde un servicio (módulo 08). Lo que cambia es el interior decargar(): 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.
- El usuario pulsa una fila, y
TablaEmpleadosemiteelegir. - El contenedor hace
elegido.set($event). - La señal
elegidobaja aDetalleEmpleadoy, comoelegidoId, 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:
- Empieza por el dueño del dato. Si la tabla muestra algo mal, mira primero qué señal del contenedor la alimenta.
- Usa Angular DevTools, que viste en el módulo 01. Selecciona el componente y comprueba sus entradas y salidas.
- Comprueba la interfaz. Un olvido habitual son los paréntesis en el padre:
[nombre]="nombre"pasa la señal entera, y el compilador respondeTS2322: Type 'WritableSignal<string>' is not assignable to type 'string'. - Pon un
effecttemporal: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 aceptarstring,nulloundefined, recortar espacios y pasar a mayúsculas. Si no hay valor, muestraDESCONOCIDO. - 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
abiertocambie, abre o cierra el diálogo conshowModal()yclose(). - Si el usuario cierra con Escape,
abiertodebe volver afalse.
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);
}
}

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 {}

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.FiltroEstadomuestra un botón por estado másTODOS, conmodel.required.ResumenPedidosmuestra el número de pedidos y el importe total.ListaPedidospinta cada pedido, y reutiliza laInsigniaEstadodel 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);
}
}

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 |