3. Componentes, directivas, servicios e inyección de dependencias
Si el capítulo anterior explicaba cómo se organiza una aplicación Angular por fuera, este explica de qué está hecha por dentro. Un componente es la unidad mínima de interfaz; una directiva, la forma de añadir comportamiento sin crear una etiqueta nueva; un servicio, el lugar donde vive la lógica que no es pintar; y la inyección de dependencias, el mecanismo que une las tres piezas sin que ninguna tenga que saber cómo se construyen las demás. Aquí se decide si tu código será mantenible durante años o un montón de clases de mil líneas acopladas al DOM. La segunda mitad del capítulo es un tratado sobre el inyector de Angular: la parte del framework que más se pregunta en entrevistas y la que más errores en tiempo de ejecución provoca cuando no se entiende.
3.1 Qué vas a poder hacer al terminar
- Decidir con un criterio explícito y defendible si un trozo de interfaz debe ser un componente, una directiva, un pipe, un servicio o simplemente una función.
- Configurar el decorador
@Componentconociendo el efecto real de cada opción relevante, y no solo copiando la que genera el CLI. - Separar componentes de presentación y contenedores, y explicar qué gana el proyecto con esa separación en términos de pruebas y reutilización.
- Comunicar componentes con
input(),input.required(),output()ymodel()basados en señales, sabiendo también leer y mantener el código antiguo con@Inputy@Output. - Enumerar el orden exacto de ejecución de los hooks del ciclo de vida en un árbol padre-hijo y justificar en qué hook va cada cosa.
- Consultar la vista y el contenido proyectado con
viewChild,viewChildren,contentChildycontentChildren, y explicar la diferencia entre vista y contenido. - Diseñar componentes compuestos con
ng-contenty varios selectores de proyección. - Elegir la estrategia de encapsulación de estilos adecuada y crear un sistema de temas con variables CSS sin recurrir a
::ng-deep. - Escribir directivas de atributo y directivas estructurales propias, con microsintaxis y guard de tipos para la plantilla.
- Escribir pipes puros e impuros entendiendo su coste en la detección de cambios, y saber por qué un pipe es preferible a un getter en la plantilla.
- Explicar la inversión de control, dibujar el árbol de inyectores de Angular y predecir dónde se resuelve una dependencia concreta.
- Usar
useClass,useValue,useFactory,useExisting,multi: trueeInjectionTokencon soltura, y explicar por qué no se puede inyectar una interfaz de TypeScript. - Diagnosticar en menos de un minuto un
NullInjectorError, una dependencia circular o un servicio duplicado por estar provisto dos veces.
3.2 El componente como unidad
Un componente es una clase que asocia tres cosas: un fragmento de plantilla, el estado necesario para renderizarla y el comportamiento que responde a la interacción del usuario. Angular lo materializa en el DOM como un elemento anfitrión (host), que es la etiqueta que aparece en el documento —normalmente la del selector—, con la plantilla renderizada dentro.
Conviene ser preciso con la terminología porque el framework la usa de forma consistente y la documentación oficial la da por sabida:
| Término | Qué es exactamente |
|---|---|
| Componente | Una directiva con plantilla propia. Toda la maquinaria de las directivas (host, entradas, salidas, ciclo de vida) le aplica igual. |
| Directiva | Una clase que añade comportamiento a un elemento existente sin aportar plantilla. Es la clase base conceptual del sistema. |
| Elemento anfitrión (host) | El nodo del DOM sobre el que se instancia el componente o la directiva. Es el «yo» al que apuntan :host, @HostBinding y @HostListener. |
| Vista (view) | La instancia renderizada de una plantilla. La detección de cambios recorre vistas, no clases. Un @for con cinco elementos crea cinco vistas embebidas. |
| Plantilla | El código fuente HTML con la sintaxis de Angular. El compilador la traduce a una función de instrucciones de renderizado. |
La clase del componente es el plano; cada vista es una casa construida con ese plano. El plano se escribe una vez; las casas pueden ser cientos y cada una tiene su propio estado (su propio contador, su propia tarea seleccionada). Cuando alguien dice «mi componente se ejecuta dos veces», casi siempre lo que ocurre es que hay dos casas construidas con el mismo plano. Y cuando el ciclo de vida parece desordenado, es porque estás pensando en el plano y Angular está trabajando con las casas.
3.2.1 El decorador @Component y sus opciones
El decorador es metadatos para el compilador: no ejecuta nada en tiempo de ejecución por sí mismo. Angular lo lee en tiempo de compilación (AOT) y genera a partir de él una función de renderizado y una definición de componente. Este es el ejemplo canónico con las opciones que se usan a diario en un proyecto real.
import {
ChangeDetectionStrategy, Component, ViewEncapsulation,
input, output,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { Tarea } from '../modelos/tarea';
@Component({
// 1. Cómo se invoca el componente desde otra plantilla.
selector: 'tf-tarea-item',
// 2. Qué se puede usar DENTRO de esta plantilla: otros componentes,
// directivas y pipes. Si no está aquí, no se puede usar.
imports: [DatePipe],
// 3. La plantilla. Con templateUrl vive en un archivo aparte;
// la regla práctica es: más de 15 líneas, archivo aparte.
template: `
<article class="item" [class.hecha]="tarea().completada">
<h3>{{ tarea().titulo }}</h3>
<time>{{ tarea().vence | date:'shortDate' }}</time>
<button type="button" (click)="alternar.emit(tarea().id)">
{{ tarea().completada ? 'Reabrir' : 'Completar' }}
</button>
</article>
`,
// 4. Estilos con ámbito propio (ver 3.8).
styleUrl: './tarea-item.component.css',
// 5. Detección de cambios: OnPush siempre, por defecto (ver 3.15 y cap. 4).
changeDetection: ChangeDetectionStrategy.OnPush,
// 6. Encapsulación de estilos. Emulated es el valor por defecto.
encapsulation: ViewEncapsulation.Emulated,
// 7. Enlaces y escuchas sobre el propio elemento anfitrión (ver 3.9).
host: {
'role': 'listitem',
'[attr.aria-checked]': 'tarea().completada',
'[class.vencida]': 'estaVencida()',
},
})
export class TareaItemComponent {
readonly tarea = input.required<Tarea>();
readonly alternar = output<string>();
protected estaVencida(): boolean {
const t = this.tarea();
return !t.completada && t.vence != null && t.vence < new Date();
}
}
La tabla siguiente recoge las opciones relevantes. Están ordenadas por frecuencia de uso real, no alfabéticamente.
| Opción | Para qué sirve y qué hay que saber |
|---|---|
selector |
Cómo se instancia el componente. Admite selectores de elemento ('tf-tarea-item'), de atributo ('[tfResaltar]'), de clase ('.tarjeta') y combinaciones ('button[tfPrimario]'). Usa siempre un prefijo propio de aplicación o librería para no colisionar con elementos nativos ni con otras librerías. En un componente es opcional: sin selector el componente solo puede usarse por ruta o creándolo dinámicamente. |
template / templateUrl |
Mutuamente excluyentes. templateUrl mejora el soporte del editor y facilita las revisiones de código; template en línea es cómodo para componentes de cinco líneas. No hay diferencia de rendimiento en compilación AOT: ambas acaban compiladas dentro del bundle. |
styles / styleUrl / styleUrls |
Estilos con ámbito de componente. styleUrl (singular) admite una sola hoja y es lo habitual; styleUrls admite un array. Ojo: un componente sin estilos propios pesa menos y suele ser señal de un buen sistema de diseño. |
imports |
La lista de dependencias de plantilla: componentes, directivas y pipes que esa plantilla utiliza. Es el sustituto del NgModule y la razón principal por la que los componentes independientes son mejores: las dependencias son explícitas y locales, y el tree shaking funciona. |
changeDetection |
Default u OnPush. Determina si la vista se comprueba en cada ciclo o solo cuando se marca como sucia. Trátalo como obligatorio en OnPush; el detalle está en el capítulo 4. |
encapsulation |
Emulated (por defecto), None o ShadowDom. Ver 3.8. |
providers |
Crea un inyector de elemento propio para este componente. Cada instancia recibe su propia instancia de los servicios listados, compartida con sus hijos y con su contenido proyectado. Ver 3.13. |
viewProviders |
Como providers, pero los servicios no son visibles para el contenido proyectado desde fuera, solo para la vista propia. Es la opción correcta cuando un servicio interno no debe filtrarse a componentes que el usuario de tu librería inserta con ng-content. |
host |
Objeto con atributos estáticos, enlaces ([...]) y escuchas ((...)) sobre el elemento anfitrión. Alternativa declarativa a @HostBinding y @HostListener; ver 3.9. |
hostDirectives |
Aplica otras directivas al anfitrión de este componente, permitiendo composición de comportamiento sin herencia. Ver 3.10. |
exportAs |
Nombre con el que la plantilla que usa la directiva puede capturar su instancia en una referencia local: #f="ngForm" funciona porque NgForm declara exportAs: 'ngForm'. |
animations |
Definiciones del sistema de animaciones de Angular. El equipo recomienda hoy resolver la mayoría de los casos con CSS y transiciones nativas; comprueba el estado de esta API en la versión que uses antes de apoyarte en ella. |
preserveWhitespaces |
Por defecto false: el compilador elimina espacios en blanco irrelevantes. Cambiarlo a true es raro y casi siempre indica que se está maquetando con espacios en lugar de con CSS. |
schemas |
Relaja la validación de la plantilla (por ejemplo CUSTOM_ELEMENTS_SCHEMA para web components de terceros). Usarlo desactiva una red de seguridad valiosa: es la última opción, no la primera. |
standalone |
Marca el componente como independiente de NgModule. Su valor por defecto ha cambiado con las versiones de Angular; véase el aviso siguiente. |
standalone
La bandera standalone apareció en la v14 con valor por defecto false, de modo que había que escribir standalone: true explícitamente. A partir de la v19 el valor por defecto pasó a ser true y lo que se marca de forma explícita es el caso contrario, standalone: false, para los componentes que siguen declarados en un NgModule. Consecuencia práctica: si ves código con standalone: true, es correcto pero redundante en versiones modernas; y si copias un ejemplo sin la bandera, comprueba la versión del proyecto antes de dar por hecho su comportamiento. Verifica siempre con ng version y con la documentación de tu versión concreta en lugar de asumir.
3.2.2 Criterio para decidir qué debe ser un componente
La pregunta no es «¿puedo hacer un componente de esto?» —siempre se puede— sino «¿me sale más barato el proyecto si esto es un componente?». Un componente tiene un coste fijo: un archivo más, un selector más en el vocabulario del equipo, una frontera de entradas y salidas que hay que documentar y mantener, y un nodo más en el árbol de detección de cambios. Ese coste se paga con gusto cuando se cumple alguna de estas condiciones.
Extrae un componente cuando…
- El fragmento se repite en dos o más sitios, o vas a necesitarlo pronto en otro.
- Tiene estado propio de interfaz que a nadie más le importa: si un desplegable está abierto, qué pestaña está activa, si el modo de edición en línea está encendido.
- Puedes darle un nombre del dominio sin usar la palabra «y»: «tarjeta de tarea», «selector de etiquetas», «avatar de usuario». Si necesitas la conjunción, probablemente son dos componentes.
- La plantilla del padre ya no cabe en una pantalla y hay que hacer scroll para entenderla.
- Quieres aislar el repintado: con
OnPush, un componente es la unidad de granularidad de la detección de cambios. - Necesitas probarlo por separado con entradas concretas, sin montar la pantalla completa.
No extraigas un componente cuando…
- Solo quieres «acortar» la plantilla y el resultado es un componente con ocho entradas y cinco salidas que no significa nada por sí mismo.
- Lo que quieres es añadir comportamiento a un elemento existente: eso es una directiva (3.9).
- Lo que quieres es transformar un valor para mostrarlo: eso es un pipe (3.11).
- Lo que quieres es compartir lógica o estado entre varios sitios: eso es un servicio (3.12).
- Es un fragmento de plantilla reutilizado solo dentro de este mismo componente: usa un
ng-templateconng-container, que no crea una frontera nueva. - Es una función pura sobre datos: sácala a un módulo de utilidades y pruébala con una llamada, no con un harness de componente.
Esta tabla resume la decisión completa. Es la primera que conviene tener en la cabeza al revisar código de otros, porque el error de diseño más frecuente en Angular es meter en un componente algo que no pertenece a la capa de presentación.
| Si lo que quieres es… | Usa | Por qué |
|---|---|---|
| Pintar una estructura de DOM con estado propio | Componente | Es la única construcción con plantilla. |
| Añadir comportamiento a un elemento existente | Directiva de atributo | Reutilizable en cualquier etiqueta y composable: varias directivas conviven en un mismo elemento; varios componentes, no. |
| Decidir si algo se pinta, o pintarlo n veces | Directiva estructural o @if/@for | Manipula el ViewContainerRef, que es exactamente para eso. |
| Formatear un valor para mostrarlo | Pipe | Puro y memoizado por Angular; se prueba con una llamada a transform. |
| Compartir estado o lógica de negocio | Servicio | Vive fuera del árbol de vistas y sobrevive a los componentes que lo usan. |
| Reutilizar un fragmento dentro del mismo componente | ng-template | Coste cero: no crea clase, ni selector, ni frontera de entradas. |
| Transformar datos sin tocar el DOM | Función pura | Lo más fácil de probar y lo más barato de ejecutar. |
3.3 Componentes de presentación frente a contenedores
Esta separación —popularizada en el mundo React como presentational vs. container components y adoptada por la comunidad Angular desde 2016— es la decisión arquitectónica que más rendimiento da por unidad de esfuerzo. La idea es sencilla: un componente puede saber cómo se pinta algo o de dónde vienen los datos, pero no las dos cosas. Es el principio de responsabilidad única de SOLID aplicado a la capa de interfaz.
| Aspecto | Componente de presentación (tonto) | Contenedor (inteligente) |
|---|---|---|
| Responsabilidad | Renderizar lo que recibe y avisar de la intención del usuario | Obtener datos, coordinar servicios y decidir qué se muestra |
| Datos | Entran por input(). Nunca los busca por sí mismo | Los pide a servicios inyectados |
| Servicios inyectados | Ninguno, o solo de presentación (traducción, formato) | Los que haga falta: estado, HTTP, router, diálogos |
| Comunicación hacia arriba | output(): «el usuario ha pulsado completar» | Llama a métodos del servicio: «marca la tarea 42 como hecha» |
| Conocimiento del dominio | Solo la forma de los datos que pinta | Reglas de negocio de la pantalla y flujo de navegación |
| Reutilización | Alta: sirve en cualquier pantalla y en el catálogo de componentes | Baja por definición: es específico de una pantalla |
| Cómo se prueba | Le das entradas y compruebas el DOM y las salidas emitidas. Sin dobles de prueba | Con servicios sustituidos por dobles mediante inyección de dependencias |
| Detección de cambios | OnPush trivial: todo depende de las entradas | OnPush con señales del servicio |
El contenedor es el camarero: habla con la cocina, conoce la carta, sabe qué mesa ha pedido qué y gestiona los imprevistos. El componente de presentación es el plato: se presenta impecable y no tiene ni idea de dónde viene el pescado. Si el plato tuviera que llamar por teléfono al proveedor, no podrías servirlo en otro restaurante. Y sobre todo: para probar el plato no necesitas montar el restaurante entero.
3.3.1 Aplicado a la lista de tareas de TaskFlow
La pantalla de tareas de un proyecto de TaskFlow se descompone en tres piezas con responsabilidades disjuntas. Empezamos por el contenedor, la única de las tres que inyecta algo.
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { TareasStore } from './tareas.store';
import { TareaListaComponent } from './tarea-lista.component';
@Component({
selector: 'tf-pagina-tareas',
imports: [TareaListaComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Tareas del proyecto</h1>
<input
type="search"
[value]="filtro()"
(input)="filtro.set($any($event.target).value)"
placeholder="Filtrar por título" />
<!-- El contenedor decide QUÉ datos bajan y QUÉ pasa al recibir eventos -->
<tf-tarea-lista
[tareas]="visibles()"
[cargando]="store.cargando()"
(alternar)="store.alternarCompletada($event)"
(eliminar)="store.eliminar($event)" />
`,
})
export class PaginaTareasComponent {
protected readonly store = inject(TareasStore);
protected readonly filtro = signal('');
// La lógica de filtrado es del contenedor, no de la lista:
// la lista solo sabe pintar el array que recibe.
protected readonly visibles = computed(() => {
const texto = this.filtro().trim().toLowerCase();
const tareas = this.store.tareas();
return texto ? tareas.filter((t) => t.titulo.toLowerCase().includes(texto)) : tareas;
});
}
Y ahora la lista, que es puro cristal: entra un array, salen intenciones. Fíjate en que no importa nada del dominio salvo el tipo Tarea.
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { Tarea } from '../modelos/tarea';
@Component({
selector: 'tf-tarea-lista',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (cargando()) {
<p role="status">Cargando tareas…</p>
} @else {
<ul role="list">
@for (tarea of tareas(); track tarea.id) {
<li>
<span>{{ tarea.titulo }}</span>
<button type="button" (click)="alternar.emit(tarea.id)">Alternar</button>
<button type="button" (click)="eliminar.emit(tarea.id)">Eliminar</button>
</li>
} @empty {
<li>No hay tareas que coincidan con el filtro.</li>
}
</ul>
}
`,
})
export class TareaListaComponent {
readonly tareas = input.required<readonly Tarea[]>();
readonly cargando = input(false);
readonly alternar = output<string>();
readonly eliminar = output<string>();
}
El fallo típico es el siguiente: un componente que en teoría es «la lista de tareas» acaba inyectando el store o el HttpClient «porque total, ya que estamos». A partir de ese momento la lista solo sirve para esa pantalla y sus pruebas necesitan simular la capa de red.
@Component({ selector: 'tf-tarea-lista', /* … */ })
export class TareaListaComponent {
// 1) Se busca los datos por su cuenta: ya no se puede
// reutilizar con otro origen (proyecto, usuario, búsqueda).
private readonly http = inject(HttpClient);
private readonly ruta = inject(ActivatedRoute);
protected readonly tareas = signal<Tarea[]>([]);
constructor() {
// 2) Depende de la ruta: fuera de esa URL no funciona.
const id = this.ruta.snapshot.paramMap.get('proyectoId');
this.http.get<Tarea[]>(`/api/proyectos/${id}/tareas`)
.subscribe((t) => this.tareas.set(t));
}
// 3) Reglas de negocio dentro de la vista: la validación
// de "no se puede completar una tarea bloqueada" vive
// aquí y no se aplicará en ninguna otra pantalla.
alternar(t: Tarea): void {
if (t.bloqueada) { return; }
this.http.patch(`/api/tareas/${t.id}`, { completada: !t.completada })
.subscribe();
}
}
@Component({
selector: 'tf-tarea-lista',
changeDetection: ChangeDetectionStrategy.OnPush,
/* … */
})
export class TareaListaComponent {
// 1) Los datos entran. El componente sirve para cualquier
// origen: una ruta, una búsqueda o un array de prueba.
readonly tareas = input.required<readonly Tarea[]>();
readonly cargando = input(false);
// 2) Solo comunica la INTENCIÓN del usuario hacia arriba.
// Quién decide qué hacer con ella es el contenedor.
readonly alternar = output<string>();
readonly eliminar = output<string>();
// 3) La regla "no se puede completar una tarea bloqueada"
// vive en el servicio de dominio, donde se aplica una
// sola vez para todas las pantallas.
}
// Prueba: sin HttpTestingController, sin router, sin store.
// componente.tareas = [tareaDePrueba];
// esperar(salidas.alternar).aHaberEmitido('t-1');
3.4 Comunicación entre componentes
Angular tiene hoy dos generaciones de API para lo mismo. Las entradas y salidas basadas en señales (input(), output(), model()) aparecieron entre las versiones 17.1 y 17.2 y son la forma recomendada en proyectos nuevos. Los decoradores clásicos (@Input, @Output) siguen soportados y aparecen en todo el código escrito antes de 2024, así que hay que saber leerlos y mantenerlos. Ambas se pueden mezclar en el mismo proyecto, aunque no conviene mezclarlas en el mismo componente.
3.4.1 Entradas: input() e input.required()
import { Component, booleanAttribute, input, numberAttribute } from '@angular/core';
import { Tarea } from '../modelos/tarea';
@Component({ selector: 'tf-tarea-detalle', /* … */ })
export class TareaDetalleComponent {
// 1) OPCIONAL CON VALOR POR DEFECTO. Tipo: InputSignal<boolean>
readonly compacta = input(false);
// 2) OPCIONAL SIN VALOR POR DEFECTO. El tipo incluye undefined:
// InputSignal<string | undefined>. Hay que tratarlo en plantilla.
readonly nota = input<string>();
// 3) OBLIGATORIA. No admite valor por defecto y su tipo NO incluye
// undefined: InputSignal<Tarea>. Si el padre no la enlaza, el
// compilador de plantillas lo detecta en tiempo de compilación.
readonly tarea = input.required<Tarea>();
// 4) ALIAS: nombre público distinto del nombre de la propiedad.
// En la plantilla del padre: [proyecto]="p()"
readonly proyectoSeleccionado = input<Proyecto | null>(null, { alias: 'proyecto' });
// 5) TRANSFORMACIÓN: convierte lo que llega antes de guardarlo.
// booleanAttribute permite el estilo HTML nativo: <tf-tarea-detalle solo-lectura>
// (presencia del atributo = true, ausencia = false, "false" = false)
readonly soloLectura = input(false, { transform: booleanAttribute });
// 6) numberAttribute convierte "3" (string del atributo) en 3.
// Si no es convertible, devuelve NaN: valida cuando importe.
readonly maxSubtareas = input(10, { transform: numberAttribute });
// 7) Transformación propia: normalizar en la frontera del componente,
// para que el resto de la clase trabaje siempre con datos limpios.
readonly etiquetas = input([] as readonly string[], {
transform: (valor: string | readonly string[]) =>
typeof valor === 'string'
? valor.split(',').map((e) => e.trim()).filter(Boolean)
: valor,
});
}
Tres propiedades importantes de una entrada basada en señales, que explican por qué desplaza al decorador:
- Es de solo lectura.
InputSignalno tieneset()niupdate(). El compilador impide que un hijo modifique su propia entrada, que es uno de los errores de diseño más difíciles de depurar en Angular clásico. - Es un nodo del grafo reactivo. Puedes derivar con
computed()y reaccionar coneffect()sin necesidad dengOnChanges, y todo funciona conOnPushsin marcar nada a mano. - La obligatoriedad se comprueba en compilación. Con
@Input() tarea!: Tareael!era una promesa del programador; coninput.required()es una comprobación del compilador de plantillas.
Una señal creada con input.required() lanza un error si se lee antes de que Angular haya escrito su primer valor, y eso incluye el cuerpo del constructor y los inicializadores de campo. En la práctica: en el constructor todavía no hay entradas; a partir de ngOnInit ya están disponibles. Si necesitas derivar algo de una entrada obligatoria, hazlo con computed() —que es perezoso y no se evalúa hasta que alguien lo lee— y no con una asignación directa en el constructor.
3.4.2 Salidas: output()
output() devuelve un OutputEmitterRef con un método emit(). La diferencia frente a @Output() x = new EventEmitter() es que ya no se expone un Subject de RxJS al exterior, y que la limpieza se hace sola cuando el componente se destruye.
import { Component, input, output, outputFromObservable } from '@angular/core';
export interface CambioTarea { readonly id: string; readonly campo: keyof Tarea; }
@Component({ selector: 'tf-tarea-editor', /* … */ })
export class TareaEditorComponent {
readonly tarea = input.required<Tarea>();
// 1) Salida tipada. El nombre describe lo OCURRIDO, no lo que
// el padre debe hacer: "guardar" (hecho), no "guardarEnApi".
readonly guardar = output<Tarea>();
// 2) Salida sin datos: el tipo es void y se emite con emit().
readonly cancelar = output<void>();
// 3) Alias, igual que en las entradas.
readonly cambio = output<CambioTarea>({ alias: 'campoModificado' });
// 4) Si el evento nace de un flujo de RxJS ya existente,
// outputFromObservable lo publica como salida sin
// suscribirse a mano ni gestionar la cancelación.
readonly teclaPulsada = outputFromObservable(this.pulsaciones$);
confirmar(borrador: Tarea): void {
this.guardar.emit(borrador);
}
}
guardarEnServidor, la responsabilidad ya se ha filtrado hacia abajo y el componente ha dejado de ser reutilizable.
3.4.3 Enlace bidireccional: model() y la convención x/xChange
El enlace bidireccional de Angular no es magia: [(valor)]="x" es azúcar sintáctico para [valor]="x" (valorChange)="x = $event". De ahí sale la regla: para que un componente admita [(algo)] necesita una entrada algo y una salida algoChange. model() crea exactamente ese par en una sola línea.
@Component({
selector: 'tf-selector-prioridad',
template: `
<button (click)="subir()">Subir prioridad</button>
<span>{{ prioridad() }}</span>
`,
})
export class SelectorPrioridadComponent {
// Crea la entrada 'prioridad' Y la salida 'prioridadChange'.
// Es un ModelSignal: se lee como señal y SÍ se puede escribir.
readonly prioridad = model<number>(1);
// También existe model.required<T>()
subir(): void {
// set() actualiza el valor local y emite prioridadChange
// en la misma operación. No hay que emitir a mano.
this.prioridad.update((p) => Math.min(p + 1, 5));
}
}
// Uso en el padre:
// <tf-selector-prioridad [(prioridad)]="tarea().prioridad" />
@Component({ selector: 'tf-selector-prioridad', /* … */ })
export class SelectorPrioridadComponent {
// La convención de nombres es OBLIGATORIA: el sufijo
// 'Change' es lo que busca el compilador al ver [(x)].
readonly prioridad = input<number>(1);
readonly prioridadChange = output<number>();
subir(): void {
const siguiente = Math.min(this.prioridad() + 1, 5);
// Hay que acordarse de emitir; si se olvida, el padre
// nunca se entera y aparece el clásico "no se guarda".
this.prioridadChange.emit(siguiente);
}
}
// Con decoradores clásicos era exactamente lo mismo:
// @Input() prioridad = 1;
// @Output() prioridadChange = new EventEmitter<number>();
[(x)] es cómodo en controles de formulario, donde el componente es el valor que edita. Fuera de ahí, difumina quién es el dueño del estado: dos componentes pueden escribir el mismo dato y el flujo deja de ser unidireccional, lo que complica razonar sobre el orden de los cambios. Para entidades del dominio suele ser preferible input más output explícitos, porque el padre ve en su plantilla qué se le está pidiendo y puede validar antes de aceptar.
3.4.4 Los decoradores clásicos @Input y @Output
Los vas a encontrar en cualquier proyecto con más de dos años de vida. Merece la pena entender sus dos peculiaridades, porque son la razón de ser de la API nueva.
export class TareaAntiguaComponent implements OnChanges {
// 1) Entrada simple. El '!' es una PROMESA del programador
// de que alguien la enlazará; nadie la verifica.
@Input() tarea!: Tarea;
// 2) Alias como argumento del decorador.
@Input('proyecto') proyectoSeleccionado: Proyecto | null = null;
// 3) Obligatoriedad declarada (Angular 16+): el compilador
// de plantillas sí avisa si el padre no la enlaza.
@Input({ required: true }) usuario!: Usuario;
// 4) Transformación (Angular 16+), equivalente al transform de input().
@Input({ transform: booleanAttribute }) soloLectura = false;
// 5) SETTER: la forma clásica de reaccionar a un cambio de una
// entrada concreta. Con señales esto es un computed o un effect.
private _filtro = '';
@Input()
set filtro(valor: string) {
this._filtro = valor.trim().toLowerCase();
this.recalcularVisibles();
}
get filtro(): string { return this._filtro; }
// 6) Salida: EventEmitter extiende Subject de RxJS, así que el
// exterior puede llamar a next(), complete() o error() sobre
// ella. Es una fuga de encapsulación que output() ya no tiene.
@Output() guardar = new EventEmitter<Tarea>();
ngOnChanges(cambios: SimpleChanges): void { /* ver 3.5 */ }
}
3.4.5 Qué mecanismo usar según la relación
Esta es la tabla de decisión. El error más caro es el de la última fila: usar entradas y salidas para conectar componentes lejanos, encadenando propiedades a través de cuatro niveles que no tienen nada que ver con el dato (el llamado prop drilling).
| Relación | Mecanismo recomendado | Notas y alternativas |
|---|---|---|
| Padre → hijo (datos) | input() / input.required() |
Siempre. Si son más de seis entradas, pasa un objeto de configuración o replantea la descomposición. |
| Hijo → padre (eventos) | output() |
Emite intenciones del usuario, no órdenes. Nombra en pasado o en infinitivo neutro. |
| Padre ↔ hijo (valor editable) | model() |
Solo cuando el hijo es el editor de ese valor. En formularios, valora ControlValueAccessor (capítulo 5). |
| Padre → hijo (llamar a un método) | viewChild() |
Legítimo para acciones imperativas: enfocar(), abrir(), reiniciar(). No para pasar datos. Ver 3.6. |
| Componente → contenido proyectado | contentChild() / contentChildren() |
Patrón de componente compuesto: una pestaña se registra en su contenedor. Ver 3.6 y 3.7. |
| Hijo → padre (leer o coordinar) | Inyectar el componente padre | Un hijo puede inyectar la clase de su ancestro (mejor con { optional: true }) o, más limpio, un servicio provisto por el padre. Ver 3.13. |
| Hermanos | Subir el estado al padre común | El padre mantiene el estado, lo baja por input a los dos y recibe los output. Es el lifting state up y es la solución correcta en la mayoría de los casos. |
| Hermanos con estado grande | Servicio provisto en el padre | providers: [FiltroTareasService] en el componente padre: los dos hermanos lo inyectan y comparten la misma instancia, aislada del resto de la aplicación. |
| Lejanos o sin relación de árbol | Servicio con señales en root |
La cabecera y una tarjeta en el nivel siete no deben conocerse. Un servicio de estado (ver 3.12 y capítulo 4) es la respuesta; nunca encadenar entradas por cinco niveles. |
| Entre rutas | Parámetros de ruta o resolver | La URL es estado compartido y además compartible: si el usuario puede querer copiar el enlace, va en la URL (capítulo 6). |
3.5 El ciclo de vida completo
Un componente no es un objeto inerte: Angular lo crea, le inyecta entradas, renderiza su plantilla, la comprueba muchas veces y finalmente lo destruye. Los hooks del ciclo de vida son los puntos donde tu código puede intervenir en ese proceso. Elegir bien el hook no es una cuestión de estilo: colocar una llamada HTTP en ngAfterViewChecked puede provocar cientos de peticiones, y leer el DOM en ngOnInit devuelve undefined.
Todos los hooks tienen una interfaz homónima en @angular/core (OnInit, OnDestroy…). Implementarla es opcional en tiempo de ejecución —Angular busca el método por su nombre— pero declárala siempre: es la única forma de que TypeScript detecte una errata como ngOnint, que si no pasa desapercibida y el hook nunca se ejecuta.
| Hook | Cuándo se ejecuta | Qué hacer aquí | Qué NO hacer |
|---|---|---|---|
constructor |
Al instanciar la clase, antes de que existan entradas, plantilla ni DOM. | Solo inject() de dependencias e inicialización de campos con valores constantes. Es el único sitio con contexto de inyección garantizado. |
Leer entradas, tocar el DOM, lanzar peticiones o suscribirse a nada. |
ngOnChanges |
Antes del primer ngOnInit y después cada vez que el padre cambia el valor de alguna entrada enlazada. |
Reaccionar a un cambio de entrada en componentes con decoradores clásicos, inspeccionando SimpleChanges. |
Trabajo costoso: se llama en cada cambio. Con entradas de señal, esto es un computed. |
ngOnInit |
Una sola vez, tras recibir las primeras entradas. | Inicialización que depende de las entradas: cargar datos, construir formularios, arrancar suscripciones. | Leer viewChild (aún no hay vista) ni suponer que se ejecuta una vez por URL: al reutilizar el componente entre rutas puede no volver a llamarse. |
ngDoCheck |
En cada ciclo de detección de cambios que alcance esta vista, antes de comprobar la plantilla. | Detección manual de cambios que Angular no ve (mutación de un objeto de entrada). Recurso de último minuto. | Cualquier cosa que no sea inmediata, y por supuesto modificar estado: se ejecuta decenas de veces por segundo. |
ngAfterContentInit |
Una vez, cuando el contenido proyectado con ng-content ya está inicializado. |
Trabajar con los resultados de contentChild/contentChildren: registrar pestañas, propagar configuración a los hijos proyectados. |
Suponer que el contenido está pintado en el DOM con su tamaño final. |
ngAfterContentChecked |
Tras comprobar el contenido proyectado, en cada ciclo. | Casi nada. Comprobaciones muy baratas sobre el contenido. | Modificar propiedades enlazadas: provoca NG0100. |
ngAfterViewInit |
Una vez, cuando la vista propia y las de los hijos ya están creadas. | Primer acceso seguro a viewChild: dar el foco, inicializar una librería de terceros (gráficas, mapas, editores), medir el DOM. |
Modificar estado enlazado a la plantilla sin cuidado: es la fuente número uno de NG0100. |
ngAfterViewChecked |
Tras comprobar la vista propia y las de los hijos, en cada ciclo. | Prácticamente nada. Si crees que lo necesitas, revisa el diseño primero. | Escribir en propiedades de la plantilla o disparar peticiones: bucle infinito garantizado. |
ngOnDestroy |
Justo antes de que Angular destruya la instancia. | Liberar todo lo que no gestiona el framework: suscripciones manuales, temporizadores, listeners globales, observers, sockets. | Confiar en que se ejecutará en el cierre de la pestaña del navegador: no está garantizado. |
3.5.1 Orden exacto en un árbol padre-hijo
El orden sorprende la primera vez porque no es «padre entero, luego hijo entero». Angular inicializa el padre hasta el punto en que necesita renderizar su plantilla; ahí crea los hijos, los inicializa por completo, y solo entonces puede afirmar que su propia vista está lista. De ahí la regla mnemotécnica: la inicialización baja y la confirmación de vista sube.
PRIMERA PASADA · creación de <tf-padre> que contiene <tf-hijo>
PADRE HIJO
───────────────────────────── ─────────────────────────────
1 constructor
2 ngOnChanges (si tiene entradas enlazadas)
3 ngOnInit
4 ngDoCheck
5 ngAfterContentInit
6 ngAfterContentChecked
│
└─ se ejecuta la plantilla del padre ─┐
▼
7 constructor
8 ngOnChanges
9 ngOnInit
10 ngDoCheck
11 ngAfterContentInit
12 ngAfterContentChecked
13 ngAfterViewInit
14 ngAfterViewChecked
┌─────────────────────────────────────┘
▼
15 ngAfterViewInit <-- aquí (y no antes) los viewChild del padre
16 ngAfterViewChecked tienen valor: sus hijos ya existen
▼
17 callbacks de afterNextRender / afterEveryRender
(tras escribir el DOM, fuera de la detección de cambios)
PASADAS SIGUIENTES · el padre cambia [tarea] del hijo
PADRE ngDoCheck ─> ngAfterContentChecked
│
├─> HIJO ngOnChanges ─> ngDoCheck
│ ─> ngAfterContentChecked
│ ─> ngAfterViewChecked
▼
ngAfterViewChecked
Nota: ngOnInit, ngAfterContentInit y ngAfterViewInit NO se repiten.
DESTRUCCIÓN · el padre desaparece (navegación, @if que pasa a falso)
PADRE ngOnDestroy ─> HIJO ngOnDestroy ─> NIETO ngOnDestroy
(de arriba hacia abajo: el padre se entera primero)
El orden anterior es estable y observable con Angular DevTools, pero es un detalle de implementación del motor de renderizado, no un contrato público de API. Si tu código solo funciona porque el ngAfterContentChecked del padre ocurre antes del constructor del hijo, tienes un problema de diseño esperando a la siguiente versión menor. Usa el orden para depurar y para elegir el hook correcto; para coordinar componentes, usa señales, un servicio compartido o consultas de contenido.
3.5.2 constructor frente a ngOnInit
La pregunta clásica de entrevista. La respuesta corta: el constructor pertenece a TypeScript y el ngOnInit pertenece a Angular. Cuando el constructor se ejecuta, el framework tiene la clase pero todavía no le ha dado nada.
export class TareaDetalleComponent {
@Input() tareaId!: string;
protected tarea: Tarea | null = null;
constructor(private readonly api: TareasApi) {
// 1) tareaId es undefined: Angular escribe las entradas
// DESPUÉS de construir la clase. La petición se hará
// contra /api/tareas/undefined.
this.api.obtener(this.tareaId)
.subscribe((t) => (this.tarea = t));
// 2) El constructor deja de ser barato y las pruebas
// unitarias no pueden instanciar la clase sin red.
}
@ViewChild('titulo') titulo!: ElementRef<HTMLElement>;
ngOnInit(): void {
// 3) La vista aún no existe: titulo es undefined y esto
// lanza TypeError al leer nativeElement.
this.titulo.nativeElement.focus();
}
}
export class TareaDetalleComponent implements OnInit, AfterViewInit {
// 1) El constructor implícito solo resuelve dependencias.
private readonly api = inject(TareasApi);
private readonly destroyRef = inject(DestroyRef);
readonly tareaId = input.required<string>();
protected readonly tarea = signal<Tarea | null>(null);
private readonly titulo =
viewChild<ElementRef<HTMLElement>>('titulo');
ngOnInit(): void {
// 2) Aquí las entradas ya tienen valor.
this.api.obtener(this.tareaId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((t) => this.tarea.set(t));
}
ngAfterViewInit(): void {
// 3) Y aquí la vista ya está creada.
this.titulo()?.nativeElement.focus();
}
}
3.5.3 ngOnChanges y SimpleChanges
ngOnChanges recibe un objeto cuyas claves son los nombres de las entradas que han cambiado en esa pasada. Una entrada que no ha cambiado no aparece en el objeto: hay que comprobar la existencia de la clave antes de leerla. Cada valor es un SimpleChange con previousValue, currentValue y el método isFirstChange().
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
export class TareaGraficaComponent implements OnChanges {
@Input({ required: true }) datos!: readonly PuntoSerie[];
@Input() escala = 1;
ngOnChanges(cambios: SimpleChanges): void {
// 1) SIEMPRE comprobar la clave: 'datos' solo está presente
// si esa entrada concreta ha cambiado en esta pasada.
if (cambios['datos']) {
const c = cambios['datos'];
// 2) isFirstChange() distingue la inicialización de una
// actualización posterior. En la primera llamada,
// previousValue es undefined.
if (c.isFirstChange()) {
this.crearGrafica(c.currentValue);
} else {
this.actualizarGrafica(c.previousValue, c.currentValue);
}
}
// 3) Reaccionar solo a lo que importa: si únicamente cambia
// la escala, no hace falta recalcular toda la serie.
if (cambios['escala'] && !cambios['datos']) {
this.reescalar(this.escala);
}
}
}
// ATENCIÓN a la comparación: Angular compara referencias con ===
// para los objetos. Si el padre hace this.datos.push(nuevo), la
// referencia del array no cambia y ngOnChanges NO se ejecuta.
// Con OnPush, además, la vista tampoco se refresca. La solución
// es la inmutabilidad: this.datos = [...this.datos, nuevo];
ngOnChanges casi desaparece
Las tres razones históricas para usar ngOnChanges tienen hoy una respuesta mejor: derivar un valor de una entrada es computed(); actuar sobre el mundo exterior cuando cambia una entrada es effect(); y reaccionar a una entrada concreta era el caso del setter, que ahora es un computed que solo depende de esa señal. ngOnChanges sigue siendo la herramienta correcta cuando de verdad necesitas el valor anterior —por ejemplo para animar una transición entre dos estados— o cuando mantienes código con decoradores clásicos. Nota importante: ngOnChanges se ejecuta también con entradas de señal, pero mezclar ambos estilos en la misma clase hace el flujo difícil de seguir.
3.5.4 Hooks de renderizado: afterNextRender y afterEveryRender
Los hooks clásicos se ejecutan durante la detección de cambios, y eso tiene dos consecuencias molestas: escribir estado ahí puede romper la pasada (NG0100), y leer geometría del DOM (offsetHeight, getBoundingClientRect()) fuerza un recálculo de layout en el peor momento posible. Los hooks de renderizado se registran como funciones dentro del contexto de inyección y se ejecutan después de que el DOM se haya escrito, fuera del ciclo, y solo en el navegador: durante el renderizado en servidor no se ejecutan, lo que los convierte en el lugar natural para el código que necesita APIs del navegador.
import { Component, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
export class TableroTareasComponent {
private readonly lienzo = viewChild.required<ElementRef<HTMLCanvasElement>>('lienzo');
constructor() {
// Se registra en el contexto de inyección (constructor o campo).
// Se ejecuta UNA vez, tras el siguiente renderizado, ya en el DOM.
afterNextRender({
// Fase de lectura temprana: leer geometría antes de escribir.
earlyRead: () => this.lienzo().nativeElement.getBoundingClientRect(),
// Fase de escritura: recibe el resultado de earlyRead.
write: (caja) => {
const el = this.lienzo().nativeElement;
el.width = caja.width;
el.height = caja.height;
this.dibujar(el.getContext('2d')!);
},
});
}
}
// Separar lectura y escritura en fases distintas evita el
// "layout thrashing": alternar medir y escribir en el mismo
// bucle obliga al navegador a recalcular el layout cada vez.
Esta familia de API se introdujo en Angular v17 en developer preview con los nombres afterRender (en cada renderizado) y afterNextRender (una sola vez). En versiones posteriores la variante repetida pasó a llamarse afterEveryRender, con afterRender marcado como obsoleto, y se añadió afterRenderEffect, que combina el concepto con el grafo de señales. Los nombres de las fases (earlyRead, write, mixedReadWrite, read) también se han ajustado entre versiones.
Cómo comprobarlo en tu proyecto, sin fiarte de ningún libro ni de ningún ejemplo de internet: ejecuta ng version para saber la versión exacta; escribe import { after } from '@angular/core'; y deja que el autocompletado del editor te ofrezca los nombres realmente exportados; consulta la referencia de API oficial de tu versión en angular.dev; y si actualizas, ejecuta ng update, que incluye migraciones automáticas para los renombrados. Si un nombre no aparece en el autocompletado, no existe en tu versión: no lo escribas «a ver si suena».
3.5.5 ngOnDestroy y DestroyRef
Todo lo que tu componente arranca y el framework no conoce debe pararse cuando el componente muere. Angular limpia lo suyo: enlaces de plantilla, escuchas declaradas con (evento), suscripciones del AsyncPipe, toSignal, salidas y efectos creados en el contexto de inyección. No limpia lo que tú creas a mano.
| Recurso | Lo limpia Angular | Cómo se libera si no |
|---|---|---|
subscribe() manual | No | takeUntilDestroyed() como último operador, o guardar la Subscription y llamar a unsubscribe() |
setInterval / setTimeout | No | clearInterval / clearTimeout en la destrucción |
addEventListener en window o document | No | removeEventListener, o mejor @HostListener('window:…'), que sí gestiona Angular |
ResizeObserver, IntersectionObserver | No | disconnect() |
WebSocket, EventSource | No | close() |
AsyncPipe, toSignal, output, effect | Sí | Nada que hacer: es el motivo para preferirlos |
import { DestroyRef, Injectable, effect, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Injectable()
export class SincronizacionService {
private readonly destroyRef = inject(DestroyRef);
constructor() {
// FORMA 1 · takeUntilDestroyed: la preferida con RxJS.
// Dentro del contexto de inyección puede omitirse el argumento;
// fuera de él (por ejemplo en ngOnInit) hay que pasar el DestroyRef.
interval(30_000)
.pipe(takeUntilDestroyed())
.subscribe(() => this.sincronizar());
// FORMA 2 · DestroyRef.onDestroy: un callback de limpieza para
// recursos que no son observables. Devuelve una función para
// cancelar el propio registro. Funciona en servicios, directivas
// y funciones auxiliares, no solo en componentes.
const observador = new ResizeObserver(() => this.recolocar());
this.destroyRef.onDestroy(() => observador.disconnect());
// FORMA 3 · el propio effect() se destruye con su contexto.
effect(() => this.registrar(this.estado()));
}
// FORMA 4 · el hook clásico, que sigue siendo perfectamente válido:
// ngOnDestroy(): void { this.socket.close(); }
// Ventaja de DestroyRef: la limpieza se declara JUNTO a la creación
// del recurso, no cuarenta líneas más abajo en otro método. Menos
// ocasiones de olvidarla al añadir o quitar código.
}
3.6 Consultas de vista y de contenido
Hay ocasiones en las que la comunicación declarativa no basta y un componente necesita una referencia al objeto real: al elemento del DOM al que dar el foco, a la instancia del hijo cuyo método hay que invocar, a la lista de pestañas que le han proyectado. Para eso existen las consultas, y la primera distinción que hay que tener clara es entre vista y contenido.
VISTA frente a CONTENIDO PROYECTADO
Plantilla del PADRE (quien usa el componente)
┌──────────────────────────────────────────────────────────────┐
│ <tf-panel> │
│ <tf-tarea-item [tarea]="t()" /> <-- (A) esto lo ESCRIBE │
│ <p>Sin tareas</p> el padre │
│ </tf-panel> │
└──────────────────────────────────────────────────────────────┘
│ (A) se proyecta hacia dentro
▼
Plantilla de tf-panel (el componente)
┌──────────────────────────────────────────────────────────────┐
│ <header> │
│ <h2 #titulo>Panel</h2> <-- (B) VISTA propia │
│ </header> │
│ <div class="cuerpo"> │
│ <ng-content /> <-- aquí aterriza (A) │
│ </div> │
│ <tf-pie /> <-- (B) VISTA propia │
└──────────────────────────────────────────────────────────────┘
Desde la clase de tf-panel:
(B) viewChild('titulo') -> lo que YO declaro
(B) viewChild(PieComponent) -> lo que YO declaro
(A) contentChild(TareaItemComponent) -> lo que me DAN
Regla mnemotécnica: la VISTA es lo que escribo en MI plantilla;
el CONTENIDO es lo que otro mete por mi <ng-content>.
Consecuencia: el contenido existe ANTES que mi vista (lo creó
el padre), y por eso ngAfterContentInit precede a ngAfterViewInit.
3.6.1 Las cuatro consultas como señales
import {
Component, ElementRef, contentChild, contentChildren, viewChild, viewChildren,
} from '@angular/core';
@Component({
selector: 'tf-panel',
template: `
<h2 #titulo>{{ etiqueta() }}</h2>
<ul>
@for (f of filas(); track f.id) {
<tf-fila [fila]="f" />
}
</ul>
<ng-content />
`,
})
export class PanelComponent {
// 1) VISTA · por referencia de plantilla (#titulo).
// Tipo: Signal<ElementRef<HTMLElement> | undefined>
private readonly titulo = viewChild<ElementRef<HTMLElement>>('titulo');
// 2) VISTA obligatoria: el tipo NO incluye undefined, pero
// lanza un error si no se encuentra. Úsalo solo cuando el
// elemento esté SIEMPRE presente (no dentro de un @if).
private readonly cabecera = viewChild.required<ElementRef>('titulo');
// 3) VISTA · por tipo de componente o directiva.
// Devuelve la INSTANCIA de la clase, no el elemento del DOM.
private readonly primeraFila = viewChild(FilaComponent);
// 4) VISTA · varias coincidencias. Tipo: Signal<readonly FilaComponent[]>
// Con @for, la señal se actualiza al cambiar la lista.
private readonly filasVista = viewChildren(FilaComponent);
// 5) CONTENIDO · lo que me proyectan desde fuera.
private readonly accionPrincipal = contentChild(BotonComponent);
private readonly acciones = contentChildren(BotonComponent);
// 6) read: qué quiero recibir de la coincidencia. Sin read,
// Angular devuelve la instancia de la directiva si el selector
// es una clase, o el ElementRef si es una referencia de plantilla.
private readonly elementoFila = viewChild(FilaComponent, { read: ElementRef });
private readonly contenedorFila = viewChild(FilaComponent, { read: ViewContainerRef });
// 7) descendants (solo en consultas de contenido): por defecto false
// en contentChildren, es decir, solo hijos directos del contenido.
// Con true, busca en todo el subárbol proyectado.
private readonly todosLosBotones = contentChildren(BotonComponent, { descendants: true });
}
3.6.2 Cuándo están disponibles los resultados
Esta es la causa del clásico «me sale undefined». Una consulta no puede resolverse antes de que exista lo consultado, y con @if o @for el resultado cambia a lo largo de la vida del componente.
| Consulta | Disponible desde | Con contenido condicional |
|---|---|---|
contentChild / contentChildren | ngAfterContentInit | La señal se actualiza cuando el conjunto proyectado cambia. |
viewChild / viewChildren | ngAfterViewInit | Devuelve undefined mientras el @if sea falso, y el elemento cuando sea verdadero. |
Decorador con { static: true } | ngOnInit | No se permite: solo funciona si el elemento no está dentro de ningún bloque condicional o repetido. |
Como señal, leída en un effect | En cuanto tiene valor | El efecto se vuelve a ejecutar al aparecer o desaparecer el elemento. Es la forma robusta. |
@Component({
template: `
@if (abierto()) {
<input #campo />
}
`,
})
export class BuscadorComponent implements AfterViewInit {
@ViewChild('campo') campo!: ElementRef<HTMLInputElement>;
protected readonly abierto = signal(false);
ngAfterViewInit(): void {
// 1) abierto() es false en el primer render: el input no
// existe y campo es undefined -> TypeError.
this.campo.nativeElement.focus();
}
abrir(): void {
this.abierto.set(true);
// 2) Y aquí tampoco: la vista no se ha vuelto a renderizar
// todavía. El truco del setTimeout(0) "funciona" por
// accidente y falla en cuanto cambia la planificación.
setTimeout(() => this.campo.nativeElement.focus());
}
}
@Component({
template: `
@if (abierto()) {
<input #campo />
}
`,
})
export class BuscadorComponent {
// 1) La consulta es una SEÑAL: su valor cambia cuando el
// elemento aparece o desaparece del DOM.
private readonly campo =
viewChild<ElementRef<HTMLInputElement>>('campo');
protected readonly abierto = signal(false);
constructor() {
// 2) El efecto reacciona al momento exacto en que el
// elemento existe. Sin setTimeout y sin condiciones
// de carrera: si nunca se abre, nunca se ejecuta.
effect(() => this.campo()?.nativeElement.focus());
}
abrir(): void { this.abierto.set(true); }
}
static: qué era y por qué ya casi no importa La opción { static: true } de @ViewChild nació en Angular 8 para resolver una ambigüedad: si el elemento consultado no está dentro de un bloque condicional, se puede resolver antes de la primera detección de cambios y por tanto está disponible ya en ngOnInit. Con static: false (el valor por defecto) se resuelve después, en ngAfterViewInit. Las consultas basadas en señales no tienen esta opción y no la necesitan: al ser reactivas, se leen cuando tienen valor y punto. Si mantienes código con static: true, comprueba que el elemento no esté dentro de un @if, un @for o un *ngIf: si lo está, la consulta devolverá undefined para siempre.
Usar viewChild para leer o escribir el estado de un hijo (this.hijo().tareas = […]) rompe el contrato de entradas y salidas, se salta OnPush y acopla el padre a la implementación interna del hijo. La regla: las consultas son para acciones imperativas que no se pueden expresar como datos —dar el foco, hacer scroll, reproducir un vídeo, medir— y para el patrón de componente compuesto. Los datos siempre por input.
3.7 Proyección de contenido
La proyección responde a una pregunta de diseño: cómo escribir un componente que aporte estructura y comportamiento sin decidir el contenido. Un panel plegable sabe plegarse, pero no debe saber qué hay dentro. Sin proyección, la única salida es una entrada por cada trozo de texto, y el componente acaba con quince entradas y sin poder aceptar un botón. Con ng-content, el hueco lo rellena quien lo usa.
@Component({
selector: 'tf-tarjeta',
template: `
<article class="tarjeta">
<header>
<!-- 1) Proyección SELECTIVA: solo lo que case con el selector.
Admite selectores CSS: etiqueta, .clase, [atributo]. -->
<ng-content select="[tarjetaTitulo]">
<!-- 2) CONTENIDO POR DEFECTO: se pinta si nadie proyecta
nada que case con este selector. -->
<h3>Sin título</h3>
</ng-content>
<ng-content select="[tarjetaAcciones]" />
</header>
<!-- 3) El ng-content SIN select recoge todo lo demás. Debe ir
DESPUÉS de los selectivos: el orden de declaración
determina qué se lleva cada hueco. -->
<div class="cuerpo"><ng-content /></div>
<footer><ng-content select="tf-tarjeta-pie" /></footer>
</article>
`,
})
export class TarjetaComponent {}
<tf-tarjeta>
<h3 tarjetaTitulo>{{ tarea().titulo }}</h3>
<button tarjetaAcciones type="button" (click)="editar()">Editar</button>
<p>{{ tarea().descripcion }}</p> <!-- va al hueco sin select -->
<tf-tarjeta-pie>Vence el {{ tarea().vence | date }}</tf-tarjeta-pie>
</tf-tarjeta>
Tres propiedades de la proyección que hay que interiorizar, porque explican casi todas las dudas que genera:
- El contenido lo crea el padre, no el componente. Sus expresiones se evalúan en el contexto del padre y su detección de cambios pertenece a la vista del padre. Por eso el contenido ya existe cuando el componente se inicializa.
- Se mueve, no se duplica. El nodo proyectado se instancia una sola vez y se coloca en el hueco. Si envuelves un
ng-contenten un@if, el contenido se crea y se destruye con él; si necesitas mostrarlo condicionalmente sin recrearlo, usa CSS o unng-templateconngTemplateOutlet. - Un
ng-contentno se puede repetir. Para pintar el mismo contenido varias veces (una celda de tabla por fila) hace faltang-templateconngTemplateOutlet, que sí admite instanciación múltiple y contexto.
3.7.1 El patrón de componente compuesto
Combinando proyección y consultas de contenido se obtiene el patrón que usan todas las librerías de interfaz serias: un componente contenedor coordina un conjunto de componentes hijos que el usuario coloca libremente, sin que el usuario tenga que gestionar el estado compartido. Las pestañas son el ejemplo canónico.
@Component({
selector: 'tf-pestana',
template: `@if (activa()) { <div role="tabpanel"><ng-content /></div> }`,
})
export class PestanaComponent {
readonly titulo = input.required<string>();
// El contenedor es quien manda: el elemento solo expone estado.
readonly activa = signal(false);
}
@Component({
selector: 'tf-pestanas',
imports: [],
template: `
<div role="tablist">
@for (p of pestanas(); track p.titulo()) {
<button role="tab" type="button"
[attr.aria-selected]="p.activa()"
(click)="activar(p)">{{ p.titulo() }}</button>
}
</div>
<ng-content />
`,
})
export class PestanasComponent implements AfterContentInit {
// Consulta de CONTENIDO: las pestañas las escribe quien usa
// el componente, no esta plantilla.
readonly pestanas = contentChildren(PestanaComponent);
ngAfterContentInit(): void {
// Ya hay contenido: se puede activar la primera.
const primera = this.pestanas()[0];
if (primera) { this.activar(primera); }
}
protected activar(elegida: PestanaComponent): void {
for (const p of this.pestanas()) { p.activa.set(p === elegida); }
}
}
// Uso, expresivo y sin estado en el consumidor:
// <tf-pestanas>
// <tf-pestana titulo="Tareas">…</tf-pestana>
// <tf-pestana titulo="Miembros">…</tf-pestana>
// </tf-pestanas>
private readonly padre = inject(PestanasComponent)) y registrarse en su constructor. Es el mecanismo que usa Angular en los formularios (un FormControlName se registra en su FormGroupName). Ventaja: funciona a cualquier profundidad y sin depender de descendants. Inconveniente: acopla el hijo al padre, así que el hijo ya no se puede usar suelto.
3.8 Encapsulación de estilos
El CSS es global por diseño, y eso convierte cualquier aplicación grande en un campo de minas: una regla .titulo { color: red } escrita para un componente afecta a los cuarenta que usan esa clase. Angular resuelve el problema dando ámbito a los estilos declarados en un componente. Hay tres estrategias y elegir mal tiene consecuencias visibles.
| Estrategia | Cómo funciona | Cuándo usarla | Inconvenientes |
|---|---|---|---|
Emulated(por defecto) | Angular añade un atributo único al anfitrión (_nghost-abc) y a los elementos de la plantilla (_ngcontent-abc), y reescribe los selectores para incluirlo. No usa nada del navegador. | Prácticamente siempre. Compatible con cualquier navegador y con el renderizado en servidor. | Es una emulación: los estilos heredables (tipografía, color) siguen entrando desde fuera, y una regla global suficientemente específica puede ganar. |
None | Los estilos se inyectan en el head tal cual: son globales. | Estilos base de la aplicación, resets, temas, o un componente que deliberadamente estiliza contenido proyectado desde fuera. | Pierdes el aislamiento. Un componente con None puede romper toda la aplicación desde un archivo que nadie mira. |
ShadowDom | Usa Shadow DOM nativo del navegador: aislamiento real, en ambos sentidos. | Web components reutilizables fuera de Angular, o widgets incrustados en páginas de terceros. | Ni los estilos globales ni las variables de tema entran salvo que se pasen como propiedades personalizadas; los eventos se reencaminan; algunas librerías que buscan nodos por document.querySelector dejan de funcionar. |
/* :host = el propio elemento anfitrión (<tf-tarjeta>).
Los componentes son display:inline por defecto: casi siempre
hay que declararlo explícitamente. */
:host {
display: block;
border: 1px solid var(--tf-borde, #ddd);
border-radius: var(--tf-radio, 8px);
}
/* :host con condición: se aplica solo si el anfitrión cumple
el selector. Combina muy bien con un @HostBinding de clase. */
:host(.destacada) { border-color: var(--tf-acento); }
:host([disabled]) { opacity: 0.5; pointer-events: none; }
/* :host-context = mira hacia ARRIBA, a los ancestros del anfitrión.
Es la forma correcta de reaccionar a un tema aplicado en <body>
sin que el componente tenga que recibir una entrada para ello. */
:host-context(.tema-oscuro) { background: #1e1e22; color: #eee; }
/* ::ng-deep atraviesa la encapsulación hacia ABAJO. Está
DESACONSEJADO y marcado como obsoleto desde hace años. */
:host ::ng-deep .librería-interna { padding: 0; }
::ng-deep está desaconsejado
::ng-deep (y sus alias históricos /deep/ y >>>) elimina el atributo de ámbito del selector, de modo que la regla se vuelve global. Si no lo acotas con :host delante, estás escribiendo CSS global desde un archivo que parece local: el fallo aparecerá en otra pantalla, meses después, y nadie relacionará las dos cosas. Además crea un acoplamiento a la estructura interna de otro componente, que puede cambiar en cualquier actualización de la librería sin que se considere un cambio incompatible. Está marcado como obsoleto porque el estándar en el que se basaba (shadow-piercing combinators) fue retirado de la plataforma web. Alternativas, en orden de preferencia: propiedades personalizadas CSS que la librería documente como puntos de personalización; una clase en el anfitrión más los estilos globales del tema; encapsulation: None en un componente pequeño y deliberado; y solo como último recurso, ::ng-deep siempre precedido de :host.
/* 1) Global sin querer: afecta a TODOS los .panel de la
aplicación, incluidos los de otras librerías. */
::ng-deep .panel { padding: 4px; }
/* 2) Acoplado a la estructura interna de una librería:
se rompe en la siguiente actualización menor. */
::ng-deep .mat-mdc-form-field-infix > div { color: red; }
/* 3) Colores repetidos en 30 componentes: cambiar el tema
obliga a tocar 30 archivos y a acertar en los 30. */
.boton { background: #4f46e5; color: #fff; }
/* 1) Un único origen de verdad, en los estilos globales.
Las variables CSS SÍ cruzan la encapsulación emulada
y también el Shadow DOM: es su comportamiento nativo. */
:root {
--tf-acento: #4f46e5;
--tf-texto-inverso: #fff;
--tf-radio: 8px;
}
body.tema-oscuro { --tf-acento: #a5b4fc; }
/* 2) El componente consume variables y expone las suyas
como API pública documentada de personalización. */
:host { --tf-tarjeta-relleno: 12px; }
.boton {
background: var(--tf-acento);
color: var(--tf-texto-inverso);
border-radius: var(--tf-radio);
}
/* 3) Cambiar el tema es cambiar una variable, en un sitio,
en tiempo de ejecución y sin recompilar nada. */
3.9 Directivas de atributo
Una directiva de atributo añade comportamiento a un elemento que ya existe. Su ventaja decisiva sobre un componente es la composición: en un mismo <input> pueden convivir cinco directivas, mientras que dos componentes no pueden compartir el mismo elemento anfitrión. Su selector se escribe entre corchetes y con prefijo propio, en camelCase.
import { Directive, ElementRef, HostBinding, HostListener, inject, input, output } from '@angular/core';
@Directive({ selector: '[tfArrastrable]' })
export class ArrastrableDirective {
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
// Una directiva tiene entradas y salidas igual que un componente.
// Con el mismo nombre que el selector, se enlaza así:
// <li [tfArrastrable]="tarea.id">
readonly tfArrastrable = input.required<string>();
readonly soltada = output<{ id: string; destino: string }>();
// @HostBinding enlaza una propiedad, atributo, clase o estilo
// del elemento anfitrión al valor de un campo de la clase.
@HostBinding('attr.draggable') protected readonly draggable = true;
@HostBinding('class.arrastrando') protected arrastrando = false;
@HostBinding('style.cursor') protected get cursor(): string {
return this.arrastrando ? 'grabbing' : 'grab';
}
// Accesibilidad: una interacción de ratón necesita equivalente
// de teclado, y el elemento debe ser alcanzable con tabulador.
@HostBinding('attr.tabindex') protected readonly tabindex = 0;
// @HostListener suscribe un manejador a un evento del anfitrión.
// Angular lo cancela solo al destruir la directiva.
@HostListener('dragstart', ['$event'])
protected alEmpezar(evento: DragEvent): void {
this.arrastrando = true;
evento.dataTransfer?.setData('text/plain', this.tfArrastrable());
}
@HostListener('dragend')
protected alTerminar(): void { this.arrastrando = false; }
// Eventos globales: el prefijo window: o document: permite
// escuchar fuera del anfitrión SIN addEventListener manual,
// y por tanto sin tener que acordarse de eliminarlo.
@HostListener('window:keyup.escape')
protected alCancelar(): void { this.arrastrando = false; }
}
Todo lo anterior se puede escribir también de forma declarativa con la propiedad host del decorador, que es el estilo que recomienda la guía de estilo actual de Angular. La equivalencia es exacta; la diferencia es de legibilidad y de reglas de lint.
@Directive({
selector: '[tfArrastrable]',
host: {
// Atributos estáticos: sin corchetes, valor literal.
'draggable': 'true',
'tabindex': '0',
// Enlaces: expresión evaluada en el contexto de la clase.
'[class.arrastrando]': 'arrastrando()',
'[style.cursor]': 'arrastrando() ? "grabbing" : "grab"',
// Escuchas: $event está disponible directamente.
'(dragstart)': 'alEmpezar($event)',
'(dragend)': 'arrastrando.set(false)',
'(window:keyup.escape)': 'arrastrando.set(false)',
},
})
export class ArrastrableDirective {
readonly tfArrastrable = input.required<string>();
protected readonly arrastrando = signal(false);
protected alEmpezar(e: DragEvent): void {
this.arrastrando.set(true);
e.dataTransfer?.setData('text/plain', this.tfArrastrable());
}
}
// Ventajas de host frente a los decoradores:
// · Todos los enlaces del anfitrión se ven de un vistazo, juntos.
// · Se heredan y se combinan mejor con hostDirectives (3.10).
// · Es lo que aplica la migración automática de ng update.
// Inconveniente: las expresiones son cadenas de texto, así que el
// editor las comprueba menos que el código TypeScript de un método.
innerHTML desde una directiva
Manipular el anfitrión con el.nativeElement.innerHTML = … abre la puerta a inyección de HTML y se salta la sanitización de Angular. Usa enlaces ([class], [style], [attr.…]), que son seguros por construcción y compatibles con el renderizado en servidor. Si necesitas manipulación imperativa, hazlo con Renderer2, que además funciona en entornos sin DOM real.
3.10 Directivas estructurales propias
Una directiva estructural no modifica un elemento: decide si existe y cuántas veces. Para ello manipula dos objetos que conviene entender bien, porque son la base de todo el renderizado dinámico de Angular (incluidos @if, @for, @defer y el router):
TemplateRef— una plantilla sin instanciar: el plano. Cuando escribes*tfAlgoen un elemento, el compilador envuelve ese elemento en unng-templatey te inyecta suTemplateRef. El contenido no está en el DOM: existe como instrucciones listas para ejecutar.ViewContainerRef— el hueco del DOM donde puedes instanciar plantillas o componentes, con métodos comocreateEmbeddedView(),createComponent(),clear(),move()ydetach().
import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';
import { SesionService } from './sesion.service';
// Contexto que la directiva pasa a la plantilla instanciada.
interface ContextoPermiso { readonly $implicit: string; readonly rol: string; }
@Directive({ selector: '[tfSiPermiso]' })
export class SiPermisoDirective {
private readonly plantilla = inject<TemplateRef<ContextoPermiso>>(TemplateRef);
private readonly contenedor = inject(ViewContainerRef);
private readonly sesion = inject(SesionService);
// MICROSINTAXIS. La expresión
// *tfSiPermiso="'tareas.editar'; rol as r; else sinPermiso"
// se desazucara en:
// <ng-template [tfSiPermiso]="'tareas.editar'"
// [tfSiPermisoElse]="sinPermiso">
// Regla: el primer valor va a la entrada con el NOMBRE del selector,
// y cada palabra clave posterior se concatena en camelCase.
readonly tfSiPermiso = input.required<string>();
readonly tfSiPermisoElse = input<TemplateRef<unknown> | null>(null);
private mostrando: boolean | null = null;
constructor() {
effect(() => {
const permitido = this.sesion.puede(this.tfSiPermiso());
// Guardar el estado anterior evita destruir y recrear la vista
// en cada pasada: recrear pierde el foco y el estado del DOM.
if (permitido === this.mostrando) { return; }
this.mostrando = permitido;
this.contenedor.clear();
if (permitido) {
this.contenedor.createEmbeddedView(this.plantilla, {
$implicit: this.tfSiPermiso(),
rol: this.sesion.rol(),
});
} else {
const alternativa = this.tfSiPermisoElse();
if (alternativa) { this.contenedor.createEmbeddedView(alternativa); }
}
});
}
}
3.10.1 Guard de tipos para la plantilla
El compilador de plantillas de Angular comprueba tipos, pero no puede adivinar que dentro de tu directiva una variable ya no es null. Para eso existen dos mecanismos poco conocidos y muy útiles: ngTemplateContextGuard, que tipa el contexto de la plantilla, y ngTemplateGuard_, que estrecha el tipo de la expresión de entrada.
@Directive({ selector: '[tfSiExiste]' })
export class SiExisteDirective<T> {
private readonly plantilla = inject<TemplateRef<{ $implicit: T }>>(TemplateRef);
private readonly contenedor = inject(ViewContainerRef);
readonly tfSiExiste = input.required<T | null | undefined>();
// 1) Estrecha el tipo de la EXPRESIÓN de entrada: dentro del
// bloque, el compilador sabe que el valor no es nulo.
// El nombre es literal: ngTemplateGuard_ + nombre de la entrada.
static ngTemplateGuard_tfSiExiste: 'binding';
// 2) Tipa el CONTEXTO, de modo que la variable declarada con
// "let x" tenga tipo T en lugar de any.
static ngTemplateContextGuard<T>(
_dir: SiExisteDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: NonNullable<T> } {
return true;
}
constructor() {
effect(() => {
this.contenedor.clear();
const valor = this.tfSiExiste();
if (valor != null) {
this.contenedor.createEmbeddedView(this.plantilla, { $implicit: valor });
}
});
}
}
// En la plantilla, 'tarea' es Tarea (no Tarea | null) y el
// compilador acepta tarea.titulo sin operador opcional:
// <div *tfSiExiste="tareaSeleccionada() as tarea">
// {{ tarea.titulo }}
// </div>
@if, @for y @switch son más rápidos, mejor tipados y no requieren importar nada. Las directivas estructurales propias siguen siendo la respuesta correcta para políticas transversales —permisos, banderas de funcionalidad, pruebas A/B, carga diferida bajo condición— porque encapsulan la decisión en un solo sitio y la aplican con una palabra en la plantilla. Si tu directiva estructural se limita a envolver una condición booleana simple, usa @if.
3.10.2 hostDirectives: composición sin herencia
Antes de la v15 solo había dos formas de reutilizar comportamiento entre componentes: heredar de una clase base —con todos los problemas de la herencia: acoplamiento rígido, imposibilidad de combinar dos comportamientos y un constructor base que hay que propagar— o pedir a quien usa el componente que añada la directiva a mano en cada plantilla. hostDirectives resuelve ambas cosas: aplica directivas al anfitrión de forma automática y componible.
@Component({
selector: 'tf-boton-tarea',
hostDirectives: [
// Sin exponer nada: comportamiento interno, invisible desde fuera.
FocoVisibleDirective,
{
directive: TooltipDirective,
// inputs/outputs declara qué se REEXPORTA hacia el consumidor:
// así <tf-boton-tarea tfTooltip="Completar"> funciona aunque
// la entrada pertenezca a la directiva y no al componente.
inputs: ['tfTooltip', 'tfTooltipPosicion: posicionAyuda'],
outputs: ['tooltipMostrado'],
},
],
template: `<ng-content />`,
})
export class BotonTareaComponent {}
// Detalles importantes:
// · Las directivas del anfitrión se instancian ANTES que el
// componente, así que este puede inyectarlas en su constructor.
// · Solo se admiten directivas sin selector propio (no componentes).
// · Lo que no se declara en inputs/outputs queda encapsulado:
// es composición con control explícito de la API pública.
3.11 Pipes
Un pipe es una función de transformación invocable desde la plantilla con el operador |. Su valor no está en la sintaxis, sino en dos propiedades: es declarativo (la plantilla dice qué se muestra, no cómo se calcula) y, si es puro, Angular memoiza su resultado.
| Pipe puro (por defecto) | Pipe impuro (pure: false) | |
|---|---|---|
Cuándo se ejecuta transform | Solo si cambia la referencia de alguno de sus argumentos | En cada ciclo de detección de cambios que alcance la vista |
| Coste | Prácticamente nulo entre cambios | Alto: decenas o cientos de llamadas por segundo |
| Instancia | Una por uso en la plantilla, reutilizada | Una por uso, con estado propio permitido |
| Casos legítimos | Formato, cálculo derivado, traducción, filtrado con entrada inmutable | AsyncPipe (se suscribe y emite), pipes que dependen de algo que Angular no observa |
| Trampa habitual | Recibir un array mutado: la referencia no cambia y el resultado se queda congelado | Usarlo «para que se actualice siempre»: es cambiar un fallo de diseño por un problema de rendimiento |
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'tfVencimiento', // nombre usado en la plantilla
pure: true, // valor por defecto; explícito por claridad
})
export class VencimientoPipe implements PipeTransform {
// El primer parámetro es el valor a la izquierda del |;
// los siguientes son los argumentos tras los dos puntos:
// {{ tarea.vence | tfVencimiento:'corto' }}
transform(fecha: Date | null, formato: 'corto' | 'largo' = 'largo'): string {
if (!fecha) { return 'Sin fecha límite'; }
const dias = Math.ceil((fecha.getTime() - Date.now()) / 86_400_000);
if (dias < 0) { return formato === 'corto' ? `-${-dias} d` : `Vencida hace ${-dias} días`; }
if (dias === 0) { return 'Vence hoy'; }
if (dias === 1) { return 'Vence mañana'; }
return formato === 'corto' ? `${dias} d` : `Vence en ${dias} días`;
}
}
// Un pipe es una clase inyectable: puede recibir dependencias
// (por ejemplo LOCALE_ID o un servicio de traducción) con inject().
// Y se prueba con una línea, sin TestBed:
// expect(new VencimientoPipe().transform(null)).toBe('Sin fecha límite');
3.11.1 Pipe frente a getter en la plantilla
Toda expresión de una plantilla se reevalúa en cada ciclo de detección de cambios. Un getter o una llamada a método en la plantilla se ejecutan, por tanto, muchas más veces de lo que la intuición sugiere: con Default pueden ser cientos de veces por segundo durante un movimiento del ratón. Un pipe puro, en cambio, solo recalcula cuando cambian sus argumentos.
@Component({
template: `
<!-- 1) Se llama en CADA ciclo, por cada elemento. -->
@for (t of tareasOrdenadas(); track t.id) {
<li>{{ formatear(t) }} — {{ resumen }}</li>
}
`,
})
export class ListaComponent {
@Input() tareas: Tarea[] = [];
// 2) Ordena y copia el array en cada evaluación: además de
// costoso, devuelve una referencia NUEVA cada vez, lo que
// invalida el track y puede provocar repintados en cascada.
tareasOrdenadas(): Tarea[] {
return [...this.tareas].sort((a, b) => a.orden - b.orden);
}
// 3) Un getter con trabajo real dentro. Parece gratis
// porque en la plantilla se lee como una propiedad.
get resumen(): string {
return this.tareas.filter((t) => !t.completada).length + ' pendientes';
}
formatear(t: Tarea): string { return t.titulo.toUpperCase(); }
}
@Component({
imports: [TituloPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (t of ordenadas(); track t.id) {
<li>{{ t | tfTitulo }} — {{ resumen() }}</li>
}
`,
})
export class ListaComponent {
readonly tareas = input.required<readonly Tarea[]>();
// 1) computed: se recalcula SOLO si cambia la señal de entrada,
// y devuelve la misma referencia mientras nada cambie.
protected readonly ordenadas = computed(() =>
[...this.tareas()].sort((a, b) => a.orden - b.orden),
);
protected readonly resumen = computed(
() => `${this.tareas().filter((t) => !t.completada).length} pendientes`,
);
}
// 2) El formato por elemento, en un pipe puro: memoizado por
// Angular y reutilizable en toda la aplicación.
@Pipe({ name: 'tfTitulo' })
export class TituloPipe implements PipeTransform {
transform(t: Tarea): string { return t.titulo.toUpperCase(); }
}
Si el valor deriva del estado del componente, usa un computed(): es lo más barato y lo más explícito. Si la transformación es reutilizable entre componentes, hazla un pipe puro. Deja los métodos en la plantilla exclusivamente para manejadores de eventos, que se ejecutan una vez por interacción y no por ciclo. Y no escribas un pipe impuro para filtrar u ordenar listas: Angular nunca incluyó un orderBy ni un filter integrados precisamente por eso, y el motivo está documentado desde 2016.
3.12 Servicios: por qué la lógica no vive en el componente
Un servicio es una clase sin plantilla que encapsula lógica o estado, y que Angular sabe construir e inyectar. Su razón de ser no es técnica sino de diseño: un componente tiene un ciclo de vida atado a la pantalla, y hay cosas que no pueden morir cuando el usuario cambia de vista.
| Motivo | Qué pasa si la lógica vive en el componente |
|---|---|
| Vida distinta | El estado se pierde al navegar. Vuelves a la lista y hay que recargar todo, perdiendo la posición del scroll, los filtros y la caché. |
| Reutilización | La regla «una tarea bloqueada no se puede completar» se reescribe en la lista, en el detalle y en el tablero. Un día se corrige en dos de los tres sitios. |
| Pruebas | Para probar una regla de negocio hay que renderizar un componente, esperar a la detección de cambios y consultar el DOM. Lento y frágil. |
| Sustituibilidad | Sin inyección no puedes cambiar la implementación en pruebas ni por entorno: el new es un acoplamiento en tiempo de compilación. |
| Responsabilidad única | La clase acumula presentación, red, caché, validación y navegación. Cualquier cambio en cualquiera de esas cinco cosas obliga a tocarla, y cada cambio puede romper las otras cuatro. |
La contrapartida: no todo debe ser un servicio. Un servicio «Dios» llamado DataService con cuarenta métodos es peor que la lógica repartida, porque todo el mundo lo inyecta y nadie sabe qué hace. La descomposición que funciona en TaskFlow separa cuatro responsabilidades:
// 1) ACCESO A DATOS. Solo habla HTTP: URLs, cabeceras, tipos del
// contrato de la API. No sabe nada de estado ni de pantallas.
@Injectable({ providedIn: 'root' })
export class TareasApi {
private readonly http = inject(HttpClient);
listar(proyectoId: string) { return this.http.get<TareaDto[]>(`/api/proyectos/${proyectoId}/tareas`); }
actualizar(id: string, cambios: Partial<TareaDto>) { return this.http.patch<TareaDto>(`/api/tareas/${id}`, cambios); }
}
// 2) REGLAS DE NEGOCIO. Funciones puras sobre el dominio: sin red,
// sin señales, sin Angular. Es lo más fácil de probar del proyecto.
export function puedeCompletarse(t: Tarea, u: Usuario): boolean {
return !t.bloqueada && (t.asignadaA === u.id || u.rol === 'gestor');
}
// 3) ESTADO DE LA APLICACIÓN. Mantiene la verdad en memoria y
// coordina el acceso a datos. Expone señales de solo lectura.
@Injectable({ providedIn: 'root' })
export class TareasStore {
private readonly api = inject(TareasApi);
private readonly _tareas = signal<readonly Tarea[]>([]);
private readonly _cargando = signal(false);
readonly tareas = this._tareas.asReadonly();
readonly cargando = this._cargando.asReadonly();
readonly pendientes = computed(() => this._tareas().filter((t) => !t.completada));
async alternarCompletada(id: string): Promise<void> { /* … */ }
}
// 4) PRESENTACIÓN. Servicios de interfaz sin dominio: diálogos,
// notificaciones, formato. Provistos en root y sustituibles.
@Injectable({ providedIn: 'root' })
export class NotificacionesService { mostrar(mensaje: string): void { /* … */ } }
TareasApi «traduce operaciones de tareas a peticiones HTTP». TareasStore «mantiene el estado de las tareas en memoria». Si la frase es «gestiona las tareas y los proyectos y las notificaciones», tienes tres servicios escritos en el mismo archivo.
3.13 Inyección de dependencias a fondo
La inyección de dependencias es el subsistema más antiguo de Angular —estaba ya en AngularJS en 2010— y el que más se pregunta en una entrevista. La idea cabe en una frase: una clase declara qué necesita y otro se encarga de proporcionárselo. Lo que hay detrás de esa frase es un contenedor jerárquico de objetos con un algoritmo de resolución, un ciclo de vida propio y una API de configuración considerable.
3.13.1 Inversión de control: el antes y el después
Sin inyección, una clase construye sus colaboradores con new. Eso significa que conoce sus clases concretas, sus constructores y las dependencias de sus dependencias. La inversión de control consiste en darle la vuelta a esa flecha: la clase pasa a depender de una abstracción y alguien externo decide la implementación concreta. Es el principio de inversión de dependencias (la D de SOLID).
export class TareasStore {
private readonly api: TareasApi;
constructor() {
// 1) Conoce la clase concreta Y su cadena completa de
// dependencias. Añadir un parámetro a TareasApi obliga
// a tocar todos los sitios donde se instancia.
this.api = new TareasApi(
new HttpClient(new HttpXhrBackend(/* … */)),
new ConfiguracionApi('https://api.taskflow.example'),
);
}
}
// Consecuencias medibles:
// · En pruebas no se puede sustituir TareasApi por un doble:
// el new está dentro del constructor, cableado a fuego.
// · La URL de producción está compilada en el código: cambiar
// de entorno exige recompilar.
// · Cada TareasStore crea su propio HttpClient: se pierden
// los interceptores, la caché y cualquier estado compartido.
// · Si mañana hay una TareasApiOffline, hay que editar la clase.
export abstract class TareasApi {
abstract listar(proyectoId: string): Observable<TareaDto[]>;
}
@Injectable({ providedIn: 'root' })
export class TareasStore {
// 1) Declara QUÉ necesita, no CÓMO se construye.
private readonly api = inject(TareasApi);
}
// 2) La decisión de qué implementación se usa vive en un solo
// sitio, el arranque de la aplicación, y puede depender del
// entorno sin que TareasStore se entere.
bootstrapApplication(App, {
providers: [
provideHttpClient(withInterceptors([autenticacion])),
{ provide: TareasApi, useClass: entornoDeProduccion
? TareasApiHttp
: TareasApiSimulada },
],
});
// 3) En una prueba, un doble en dos líneas:
// TestBed.configureTestingModule({
// providers: [{ provide: TareasApi, useValue: apiFalsa }],
// });
Un electrodoméstico no lleva soldado el cable a la central eléctrica: lleva un enchufe, que es un contrato. Lo que hay detrás de la pared —red pública, generador, placas solares— es responsabilidad de la instalación, no del aparato. El token de inyección es la forma del enchufe; el proveedor es la instalación eléctrica. Y por eso puedes enchufar el aparato a un generador de pruebas sin abrirlo ni modificarlo.
3.13.2 El árbol de inyectores y el algoritmo de resolución
Angular no tiene un contenedor, tiene dos jerarquías de contenedores que se recorren en un orden concreto. Entender este dibujo es lo que separa a quien resuelve un NullInjectorError en treinta segundos de quien prueba cosas al azar.
LAS DOS JERARQUÍAS DE INYECTORES
JERARQUÍA DE ENTORNO JERARQUÍA DE ELEMENTO
(independiente del DOM) (sigue el árbol de vistas)
NullInjector (no existe: al llegar
▲ lanza NullInjectorError arriba salta a la de
│ entorno correspondiente)
PlatformInjector ....... providedIn: 'platform'
▲
│
RootEnvironmentInjector . providedIn: 'root'
▲ providers de bootstrapApplication
│
RouteEnvironmentInjector providers de una ruta (lazy)
▲ ▲
│ │
└────────────────── <app-root> ...... providers de componente
▲
│
<tf-pagina-tareas> providers: [FiltroService]
▲
│
<tf-tarea-lista> (sin providers)
▲
│
<tf-tarea-item> ── inject(FiltroService)
ALGORITMO DE RESOLUCIÓN para inject(X) desde <tf-tarea-item>
1. ¿Hay X en el inyector de ELEMENTO de tf-tarea-item?
(sus providers / viewProviders / hostDirectives) -> NO
2. Subir al elemento padre y repetir ................... -> NO
3. ... hasta tf-pagina-tareas: ¡X está aquí! -> SÍ
Se devuelve LA MISMA instancia a todos los descendientes
de tf-pagina-tareas.
Si la cadena de elementos se agota sin encontrarlo:
4. Inyector de ENTORNO de la ruta actual .................
5. Inyector de entorno RAÍZ (providedIn: 'root') .........
6. Inyector de PLATAFORMA (providedIn: 'platform') .......
7. NullInjector -> NG0201: NullInjectorError:
No provider for X!
Consecuencias prácticas:
· Lo que se encuentra ANTES gana: un provider en un componente
oculta (shadowing) el de root para él y todos sus hijos.
· Un servicio en 'root' es un singleton para toda la aplicación.
· Un servicio en un componente tiene UNA INSTANCIA POR INSTANCIA
del componente, y muere con él: es estado con ámbito.
· viewProviders se salta el contenido proyectado: un hijo que
llega por <ng-content> resuelve contra el inyector de QUIEN
LO ESCRIBIÓ, no de quien lo pinta.
3.13.3 Dónde se registra un servicio
| Registro | Ámbito e instancias | Cuándo usarlo |
|---|---|---|
providedIn: 'root' | Una instancia para toda la aplicación. Es tree-shakable: si nadie lo inyecta, no entra en el bundle. | El 90 % de los casos: estado global, acceso a datos, utilidades. |
providedIn: 'platform' | Una instancia compartida por todas las aplicaciones Angular de la misma página. | Muy raro: solo con varias aplicaciones arrancadas en el mismo documento (micro-frontends, aplicaciones incrustadas) que deban compartir algo. |
providedIn: 'any' | Una instancia por cada inyector de entorno que lo solicite (raíz y cada módulo o ruta perezosa). | Casi siempre es un error de diseño disfrazado: produce el desconcertante «tengo dos instancias de mi singleton». Existe por compatibilidad con la era de los módulos perezosos. |
providers: [] en un componente | Una instancia por instancia del componente, visible para su vista y su contenido proyectado. Se destruye con el componente. | Estado con ámbito: el filtro de una pantalla, el estado de un asistente por pasos, un store por elemento de una lista. |
viewProviders: [] | Como el anterior, pero no visible para el contenido proyectado desde fuera. | Librerías de componentes: evita que el consumidor acceda por accidente a tus servicios internos. |
providers en una ruta | Una instancia mientras la ruta esté activa; se destruye al salir de ella. | Estado de una funcionalidad completa cargada de forma perezosa, que debe desaparecer al abandonarla. |
providers en bootstrapApplication | Equivale al inyector raíz. | Configuración de la aplicación: provideHttpClient, provideRouter, tokens de entorno. |
export const rutasTareas: Routes = [
{
path: 'proyectos/:id',
// Estas dos instancias viven mientras el usuario esté en esta
// sección y se destruyen al navegar fuera. Ideal para el estado
// de una funcionalidad: no contamina el resto de la aplicación.
providers: [ProyectoActualStore, BorradorTareaService],
loadComponent: () => import('./pagina-proyecto.component'),
children: [/* … los hijos comparten esas instancias … */],
},
];
// Estado POR COMPONENTE: cada tarjeta expandible tiene su propio
// servicio de edición, con su propio borrador y su propia validación.
@Component({
selector: 'tf-tarjeta-editable',
providers: [EdicionTareaService],
/* … */
})
export class TarjetaEditableComponent {
// Instancia exclusiva de ESTA tarjeta: si hay veinte tarjetas
// en pantalla, hay veinte servicios independientes. No hace
// falta ninguna clave ni ningún mapa por identificador.
protected readonly edicion = inject(EdicionTareaService);
}
3.13.4 Las recetas de proveedor
Un proveedor es un par formado por un token (la llave con la que se pide algo) y una receta (cómo se fabrica). Hay cinco recetas y conviene conocerlas todas: cada una resuelve un problema distinto y usar la equivocada produce código retorcido.
export const configuracionApp: ApplicationConfig = {
providers: [
// 1) FORMA ABREVIADA: equivale a { provide: X, useClass: X }.
TareasStore,
// 2) useClass · sustituir la implementación de un token.
// Es la receta de la inversión de dependencias.
{ provide: TareasApi, useClass: TareasApiHttp },
// 3) useValue · un valor ya construido: configuración, constantes,
// un objeto de un tercero. No se instancia nada.
{ provide: CONFIG_API, useValue: { url: '/api', reintentos: 3 } },
// 4) useFactory · cuando la construcción requiere lógica.
// deps declara qué recibe la función, EN ORDEN.
{
provide: ALMACEN_LOCAL,
useFactory: (plataforma: object) =>
isPlatformBrowser(plataforma) ? window.localStorage : new AlmacenMemoria(),
deps: [PLATFORM_ID],
},
// Alternativa moderna: dentro de una factory se puede usar
// inject() directamente y omitir deps. Es más legible y no
// se desincroniza al añadir dependencias.
{ provide: ALMACEN_LOCAL, useFactory: () => inject(PlataformaService).almacen() },
// 5) useExisting · un ALIAS: el mismo objeto con dos tokens.
// Sirve para exponer una interfaz reducida sin duplicar
// la instancia. Con useClass habría DOS instancias.
{ provide: SoloLecturaTareas, useExisting: TareasStore },
// 6) multi: true · varios proveedores para un mismo token;
// inject() devuelve un ARRAY con todos. Es el mecanismo de
// los interceptores HTTP y de los validadores de formulario.
{ provide: VALIDADOR_TAREA, useClass: ValidadorTitulo, multi: true },
{ provide: VALIDADOR_TAREA, useClass: ValidadorFecha, multi: true },
],
};
multi
Mezclar un proveedor normal y otro con multi: true para el mismo token lanza un error en tiempo de ejecución (NG0209: Cannot mix multi providers and regular providers). Y recuerda el orden de precedencia: dentro de un mismo inyector, el último proveedor declarado gana para las recetas normales; con multi, en cambio, todos se acumulan en el array.
3.13.5 InjectionToken y por qué no se puede inyectar una interfaz
El token por defecto de un servicio es su propia clase, y funciona porque una clase de TypeScript existe en tiempo de ejecución: es una función y, por tanto, puede usarse como clave en un mapa. Una interfaz o un type de TypeScript, en cambio, desaparece por completo al compilar: en el JavaScript generado no queda ni rastro. No hay ningún objeto que Angular pueda usar como llave, y por eso inject(MiInterfaz) no compila. La solución es crear explícitamente un objeto que sí exista: un InjectionToken.
import { InjectionToken, inject } from '@angular/core';
// La interfaz es el contrato para TypeScript (no existe al ejecutar).
export interface ConfiguracionTaskFlow {
readonly urlApi: string;
readonly tareasPorPagina: number;
readonly funcionalidades: readonly string[];
}
// El token es el objeto real que sirve de llave. El parámetro de
// tipo es lo que da seguridad de tipos a inject(): el resultado es
// ConfiguracionTaskFlow, no any. La cadena es SOLO para mensajes
// de error legibles; no identifica el token (dos tokens con la
// misma descripción son distintos).
export const CONFIG_TASKFLOW = new InjectionToken<ConfiguracionTaskFlow>(
'ConfiguracionTaskFlow',
{
// factory opcional: valor por defecto. Con esto el token es
// tree-shakable y NUNCA lanza NullInjectorError, porque si
// nadie lo provee, se construye con esta función.
providedIn: 'root',
factory: () => ({ urlApi: '/api', tareasPorPagina: 25, funcionalidades: [] }),
},
);
// Uso, con tipado completo y sin aserciones:
// private readonly config = inject(CONFIG_TASKFLOW);
// this.config.tareasPorPagina // number
// ALTERNATIVA a los tokens para contratos: una CLASE ABSTRACTA.
// Existe en tiempo de ejecución (sirve de token) y a la vez define
// el contrato para el compilador. Suele leerse mejor que un token.
export abstract class RelojServicio { abstract ahora(): Date; }
// { provide: RelojServicio, useClass: RelojSistema }
// En pruebas: { provide: RelojServicio, useValue: { ahora: () => new Date('2026-01-01') } }
3.13.6 inject() frente al constructor, y el contexto de inyección
Hasta la v14 la única forma de recibir dependencias era declararlas como parámetros del constructor, lo que obligaba a mantener un constructor largo y hacía muy incómoda la herencia (toda subclase debía repetir los parámetros del padre y llamar a super()). La función inject() obtiene la dependencia sin pasar por el constructor y es hoy la forma recomendada.
| Aspecto | Constructor | inject() |
|---|---|---|
| Herencia | La subclase debe repetir los parámetros y llamar a super(...) | La clase base inyecta lo suyo; la subclase no se entera |
| Uso en funciones | Imposible | Permite escribir funciones reutilizables que inyectan: guards, resolvers, interceptores funcionales |
| Inferencia de tipos | Buena | Mejor: inject(TOKEN) devuelve el tipo del token sin anotarlo |
| Modificadores | Decoradores de parámetro @Optional(), @Self()… | Objeto de opciones: { optional: true, skipSelf: true } |
| Dónde se puede llamar | Donde se instancia la clase | Solo en contexto de inyección: ahí está toda la dificultad |
// CONTEXTO DE INYECCIÓN: los lugares donde inject() es válido.
// 1. El cuerpo del constructor de una clase que Angular instancia.
// 2. Los inicializadores de campo de esa clase.
// 3. La función factory de un provider o de un InjectionToken.
// 4. Dentro de runInInjectionContext().
// FUERA de esos sitios lanza NG0203: inject() must be called from
// an injection context.
@Injectable({ providedIn: 'root' })
export class ExportacionService {
// (2) inicializador de campo: correcto y es el estilo habitual.
private readonly http = inject(HttpClient);
private readonly inyector = inject(Injector);
ngOnInit(): void {
// INCORRECTO: un hook del ciclo de vida NO es contexto de
// inyección. Se ejecuta mucho después de la construcción.
// const api = inject(TareasApi); // NG0203
}
async exportarTardio(): Promise<void> {
// (4) Cuando de verdad hace falta inyectar tarde —por ejemplo
// tras un import() dinámico— se recupera el contexto a partir
// de un Injector guardado en la construcción.
const { GeneradorCsv } = await import('./generador-csv');
runInInjectionContext(this.inyector, () => new GeneradorCsv().generar());
}
}
// El caso que más confunde: effect() y toSignal() REQUIEREN
// contexto de inyección para saber cuándo destruirse.
// constructor() { effect(() => …); } // correcto
// ngOnInit() { effect(() => …); } // NG0203
// ngOnInit() { effect(() => …, { injector: this.inyector }); } // válido
3.13.7 Modificadores de resolución
Por defecto, la búsqueda empieza en el inyector del propio elemento y sube hasta la raíz. Los cuatro modificadores alteran ese recorrido. Son imprescindibles al escribir componentes que se coordinan entre sí.
| Modificador | Efecto | Caso de uso típico |
|---|---|---|
{ optional: true } | Devuelve null en lugar de lanzar NullInjectorError si no lo encuentra. | Dependencias verdaderamente opcionales: telemetría, un componente padre que puede no estar, configuración con valor por defecto. |
{ self: true } | Busca solo en el inyector del propio elemento; no sube. | Comprobar que el consumidor ha provisto el servicio en este mismo componente, y fallar de forma clara si no lo ha hecho. |
{ skipSelf: true } | Ignora el inyector propio y empieza por el padre. | Detectar el ancestro del mismo tipo: es como un árbol de menús anidados o un formulario dentro de otro sabe quién es su contenedor sin inyectarse a sí mismo. |
{ host: true } | Detiene la búsqueda en el inyector del componente anfitrión de la vista actual. | Contenido proyectado que debe usar el servicio del componente que lo contiene, sin escapar hacia inyectores más altos. |
@Component({
selector: 'tf-nodo-arbol',
// Cada nodo provee su propio servicio de nivel...
providers: [NivelService],
template: `<ng-content />`,
})
export class NodoArbolComponent {
// ...y necesita el del PADRE para calcular su profundidad.
// Sin skipSelf se inyectaría el suyo propio y la profundidad
// sería siempre 0. Sin optional, el nodo raíz —que no tiene
// padre— lanzaría NullInjectorError.
private readonly padre = inject(NivelService, { skipSelf: true, optional: true });
private readonly propio = inject(NivelService);
constructor() {
this.propio.profundidad = (this.padre?.profundidad ?? -1) + 1;
}
}
// Equivalencia con los decoradores clásicos de parámetro:
// constructor(
// @SkipSelf() @Optional() private padre: NivelService,
// private propio: NivelService,
// ) {}
// Existen también @Self() y @Host(), y @Inject(TOKEN) para
// inyectar un token que no es una clase desde el constructor.
3.13.8 Cuatro patrones que justifican todo lo anterior
1 · Sustituir la implementación en pruebas
Es la razón número uno para usar inyección. El componente no cambia una sola línea; solo cambia quién responde al token. Sin inyección habría que interceptar la red o parchear el módulo, que es frágil y lento.
TestBed.configureTestingModule({
providers: [
{ provide: TareasApi, useValue: {
listar: () => of([tareaDePrueba]),
} },
{ provide: RelojServicio, useValue: {
ahora: () => new Date('2026-03-01'),
} },
],
});
2 · Configuración por entorno
Un token con la configuración, provisto en el arranque. El resto del código inyecta el token y nunca lee variables globales ni importa un archivo environment.ts, lo que además evita que el bundle de desarrollo llegue a producción.
bootstrapApplication(App, {
providers: [{
provide: CONFIG_TASKFLOW,
useValue: configuracionDelServidor,
}],
});
3 · Estrategia intercambiable
El patrón strategy de la banda de los cuatro, implementado por el contenedor. La lógica que decide qué implementación se usa está en un único proveedor y no salpica if por todo el código.
{
provide: AlmacenamientoTareas,
useFactory: () => inject(RedService).enLinea()
? new AlmacenamientoHttp()
: new AlmacenamientoIndexedDb(),
}
4 · Estado con ámbito de componente
Un servicio provisto en el componente da una instancia por instancia, que nace y muere con él. Resuelve sin esfuerzo el problema de «quiero un store por cada elemento de la lista» y evita el singleton global con un mapa de identificadores.
@Component({
selector: 'tf-asistente-tarea',
providers: [AsistenteStore],
})
export class AsistenteTareaComponent {
protected readonly paso = inject(AsistenteStore);
}
3.13.9 Los tres errores clásicos de inyección
| Error | Causa real | Cómo se arregla |
|---|---|---|
NG0201NullInjectorError: No provider for X |
Se ha recorrido toda la cadena de inyectores sin encontrar el token. Las causas concretas: falta @Injectable({ providedIn: 'root' }); el servicio está provisto en una ruta o componente que no es ancestro de quien lo pide; se ha olvidado provideHttpClient(); en una prueba falta el proveedor en el TestBed; o se intenta inyectar una interfaz. |
Lee el mensaje de abajo arriba: indica la cadena completa de resolución. Decide el ámbito correcto (raíz, ruta o componente) y añade el proveedor ahí. Si la dependencia es de verdad opcional, usa { optional: true } y trata el null. |
NG0200dependencia circular |
A inyecta B y B inyecta A. El contenedor no puede construir ninguno de los dos porque cada uno necesita al otro terminado. |
Es un síntoma de diseño, no un problema técnico: hay una responsabilidad mal repartida. Extrae la parte compartida a un tercer servicio C del que dependan ambos, o invierte una de las dos direcciones con un evento o una señal. Como parche temporal, inject(B, { optional: true }) diferido o inyectar el Injector y resolver más tarde; pero conviene arreglar el diseño. |
| Servicio duplicado («mi singleton tiene dos instancias») |
El mismo servicio está declarado con providedIn: 'root' y además en el array providers de un componente o de una ruta. Cada instancia del componente crea su propia copia y esconde la global para todo su subárbol: dos estados, dos cachés y datos que aparecen y desaparecen según la pantalla. La variante providedIn: 'any' produce el mismo síntoma con rutas perezosas. |
Decide el ámbito y respétalo: si es global, elimina el providers del componente; si es con ámbito, quita providedIn de la clase y déjalo solo en el componente o la ruta. Angular DevTools permite inspeccionar el inyector que resolvió cada dependencia, y ahí se ve el duplicado de inmediato. |
3.14 Buenas y malas prácticas de diseño de componentes
Haz esto
- Una responsabilidad por componente. Si el nombre necesita una conjunción, divídelo.
OnPushpor defecto en todos los componentes, desde el primer día del proyecto.- Estado y reglas en servicios; el componente orquesta y pinta.
- Entradas de solo lectura e inmutabilidad. No mutes nunca un objeto recibido por
input: el dueño es el padre. readonlyen todos los campos que no se reasignan, yprotectedpara lo que solo usa la plantilla: el modificador documenta la intención mejor que un comentario.- Nombra las salidas por lo ocurrido (
tareaCompletada), no por lo que el padre debe hacer (guardarEnApi). - Plantillas cortas. Más de una pantalla de scroll es una señal para extraer un componente.
- Prefija los selectores con las siglas del producto o de la librería (
tf-). - Un archivo por concepto y nombres consistentes:
tarea-lista.component.ts,tareas.store.ts,vencimiento.pipe.ts. - Prueba la presentación por su comportamiento observable: entradas dentro, DOM y salidas fuera. Nunca por sus métodos privados.
Evita esto
- Componentes «Dios» de 800 líneas con red, validación, navegación y presentación.
- Lógica en la plantilla: expresiones con tres operadores ternarios anidados o llamadas a métodos costosos.
- Acceso directo al DOM con
document.querySelectoroinnerHTML: rompe el renderizado en servidor y la seguridad. viewChildpara pasar datos en lugar de entradas: acopla el padre al interior del hijo.- Suscripciones sin cancelar y suscripciones anidadas.
::ng-deeppara estilizar por dentro componentes de terceros.- Encadenar entradas por cinco niveles para llevar un dato de arriba abajo.
- Herencia entre componentes para compartir comportamiento: usa
hostDirectives, composición o un servicio. anyen entradas y salidas: el contrato entre componentes es lo último que debe perder el tipado.- Estado duplicado: el mismo dato en el servicio y copiado en un campo del componente, que se desincronizan al primer descuido.
3.14.1 Accesibilidad: roles, foco y teclado
La accesibilidad no es una capa que se añade al final: es una consecuencia del diseño del componente. En la Unión Europea, además, es una obligación legal para el sector público y para buena parte de los servicios digitales privados. Tres reglas cubren la mayor parte del trabajo diario.
- Usa el elemento nativo correcto antes que un rol. Un
<button>ya es enfocable, se activa con Intro y con la barra espaciadora, se anuncia como botón y respeta las preferencias del sistema. Un<div role="button">obliga a reimplementar todo eso a mano, y casi nadie lo hace completo. La primera regla de ARIA es no usar ARIA si existe HTML que ya lo hace. - Gestiona el foco explícitamente cuando el DOM cambia. Al abrir un diálogo, el foco entra en él y queda atrapado; al cerrarlo, vuelve al elemento que lo abrió. Al eliminar una fila de una lista, el foco pasa a la siguiente y no al
<body>, porque perder el foco desorienta por completo a quien navega con teclado o lector de pantalla. - Anuncia los cambios que no se ven. Un contenedor con
role="status"oaria-live="polite"comunica «tarea guardada» o «cargando» a quien no puede verlo. Y ten cuidado con el orden: para que un lector de pantalla anuncie el cambio, el contenedor vivo debe existir en el DOM antes de que se escriba el texto.
<!-- 1) No es enfocable con el tabulador, no responde a Intro
ni a Espacio, y el lector de pantalla lo anuncia como
texto sin más: para media plantilla de usuarios, este
botón simplemente no existe. -->
<div class="boton" (click)="completar()">Completar</div>
<!-- 2) Icono sin nombre accesible: se anuncia como "botón". -->
<button (click)="eliminar()"><svg>…</svg></button>
<!-- 3) El color es la ÚNICA señal del estado. -->
<span [style.color]="tarea().vencida ? 'red' : 'green'">●</span>
<!-- 4) Lista de tareas sin semántica ni relación con su nombre. -->
<div>@for (t of tareas(); track t.id) { <div>{{ t.titulo }}</div> }</div>
<!-- 1) Elemento nativo: foco, teclado y semántica gratis.
type="button" evita el envío accidental del formulario. -->
<button type="button" class="boton" (click)="completar()">
Completar
</button>
<!-- 2) Nombre accesible para el icono; el svg se oculta al
lector porque su información ya está en el texto. -->
<button type="button" aria-label="Eliminar tarea" (click)="eliminar()">
<svg aria-hidden="true">…</svg>
</button>
<!-- 3) Estado con texto además de color. -->
<span class="estado" [class.vencida]="tarea().vencida">
{{ tarea().vencida ? 'Vencida' : 'En plazo' }}
</span>
<!-- 4) Semántica de lista y región viva para los avisos. -->
<ul role="list" aria-labelledby="tit-tareas">
@for (t of tareas(); track t.id) { <li>{{ t.titulo }}</li> }
</ul>
<p role="status">{{ aviso() }}</p>
host: { 'role': 'listitem', '[attr.aria-disabled]': 'desactivado()' }. Para el teclado, @HostListener admite modificadores de tecla ('keydown.enter', 'keydown.arrowdown', 'keydown.escape'), lo que evita comparar event.key a mano. Y para dar el foco, hazlo siempre desde afterNextRender o desde un effect que observe la consulta de vista, nunca con un setTimeout a ciegas.
3.15 Rendimiento
El rendimiento de una aplicación Angular se juega casi entero en la detección de cambios, que es el tema del capítulo 4. Desde el diseño de componentes, tres decisiones concentran la mayor parte del efecto.
OnPushen todos los componentes. Con la estrategia por defecto, cualquier evento en cualquier parte de la aplicación provoca la comprobación de todas las expresiones de todas las vistas. ConOnPush, una vista solo se comprueba si cambia una entrada por referencia, se dispara un evento en su plantilla, se marca explícitamente o cambia una señal que lee. El coste pasa de ser proporcional al tamaño de la aplicación a serlo al del cambio. La lista exacta de sucesos que marcan una vista como sucia, junto con el modo zoneless, está en el capítulo 4.- Nada de cálculos en la plantilla. Cada expresión se reevalúa en cada comprobación de esa vista. Ordenar, filtrar, mapear, formatear fechas o concatenar cadenas en la plantilla multiplica ese coste por el número de elementos. La alternativa es siempre la misma:
computed()para lo derivado del estado propio y un pipe puro para lo reutilizable (ver 3.11). - Listas grandes con estrategia. El
trackde@fores obligatorio y debe ser una identidad estable —el identificador de la entidad, nunca el índice si la lista se reordena o se filtra—, porque de él depende que Angular reutilice los nodos del DOM en lugar de destruirlos y recrearlos. Por encima de unos cientos de filas visibles, ninguna optimización de detección de cambios compensa el coste de tener miles de nodos en el DOM: hace falta paginación, desplazamiento virtual (por ejemplo elScrollingModulede Angular CDK, que solo renderiza las filas visibles) o carga diferida con@defer. Y cada fila debe ser un componenteOnPushlo más ligero posible: quinientas filas multiplican por quinientos cualquier descuido.
La intuición sobre rendimiento en interfaces es notoriamente mala. Antes de reescribir nada, usa el profiler de detección de cambios de Angular DevTools, que muestra cuántas veces se comprueba cada componente y cuánto tarda, y el panel de rendimiento del navegador para ver el reparto real entre script, layout y pintado. Casi siempre el culpable es uno solo y no es el que se sospechaba: una lista sin track, un pipe impuro o un componente con la estrategia por defecto en la raíz de una pantalla grande.
3.16 Errores comunes y cómo solucionarlos
| Síntoma o error | Causa | Solución |
|---|---|---|
NG0201: No provider for X | El token no está en ningún inyector de la cadena, o se intenta inyectar una interfaz. | Añade providedIn: 'root' o el proveedor en el ámbito adecuado; usa InjectionToken o una clase abstracta para los contratos (3.13.5). |
NG0203: inject() must be called from an injection context | Se llama a inject(), effect() o toSignal() fuera del constructor o de un inicializador de campo. | Muévelo al constructor, o pasa un Injector guardado ({ injector }) o usa runInInjectionContext (3.13.6). |
NG0100: ExpressionChangedAfterItHasBeenChecked | El valor de una expresión cambió después de comprobar la vista, normalmente por escribir estado en ngAfterViewInit o en un getter que devuelve un objeto nuevo cada vez. | No modifiques estado enlazado durante los hooks posteriores a la comprobación. Deriva con computed(); el diagnóstico completo está en el capítulo 4. |
NG0951 / NG0955 en un @for | Expresión de track que devuelve valores duplicados o no estables (por ejemplo un objeto nuevo o un índice tras reordenar). | Usa una identidad estable y única: track tarea.id. |
'tf-x' is not a known element | El componente no está en el array imports de quien usa la plantilla, o hay una errata en el selector. | Añádelo a imports. Recuerda que en componentes independientes la importación es local a cada componente. |
«El viewChild es undefined» | Se lee antes de ngAfterViewInit, o el elemento está dentro de un @if que aún es falso. | Léelo como señal desde un effect, o en ngAfterViewInit si el elemento es incondicional (3.6.2). |
| «La vista no se actualiza al cambiar un array» | Mutación en el sitio (push, splice, asignación a una propiedad): la referencia no cambia y ni OnPush ni ngOnChanges ni las señales lo detectan. | Inmutabilidad: this.tareas.update((ts) => [...ts, nueva]). |
| «El hijo no recibe el cambio» | Se muta el objeto pasado por input, o se escribe en la entrada desde el propio hijo. | Pasa un objeto nuevo desde el padre; comunica hacia arriba con output. Las entradas de señal impiden por completo el segundo caso. |
| «Tengo dos instancias de mi servicio» | providedIn: 'root' junto con providers en un componente o ruta, o providedIn: 'any' con rutas perezosas. | Elige un único ámbito y quita el otro registro (3.13.9). |
Dependencia circular (NG0200) | Dos servicios que se inyectan mutuamente. | Extrae la parte común a un tercer servicio o invierte una de las dependencias con un evento o una señal. |
| Fuga de memoria: el componente sigue reaccionando tras destruirse | Suscripción, temporizador u observer creados a mano y nunca liberados. | takeUntilDestroyed(), DestroyRef.onDestroy() o ngOnDestroy (3.5.5). |
| Un estilo del componente no se aplica al contenido proyectado | Encapsulación emulada: el contenido lleva el atributo de ámbito del padre que lo escribió, no el del componente que lo pinta. | Estiliza desde el componente que escribe el contenido, o expón variables CSS como puntos de personalización (3.8). |
| El hook del ciclo de vida no se ejecuta | Errata en el nombre del método (ngOninit) sin implementar la interfaz correspondiente. | Declara siempre implements OnInit: es la única forma de que el compilador detecte la errata. |
| La aplicación va lenta al escribir en un campo | Método o getter costoso en la plantilla, pipe impuro, o estrategia por defecto en una pantalla grande. | OnPush, computed() y pipes puros; mide con Angular DevTools (3.15). |
| El código funciona en el navegador y falla en el servidor | Uso directo de window, document o localStorage durante la construcción del componente. | Muévelo a afterNextRender, o decide con isPlatformBrowser(inject(PLATFORM_ID)). |
3.17 Preguntas frecuentes
¿Qué diferencia real hay entre el constructor y ngOnInit?
El constructor es de TypeScript y se ejecuta al instanciar la clase, cuando Angular todavía no le ha dado nada: no hay entradas, no hay plantilla y no hay DOM. ngOnInit es un hook del framework que se ejecuta una sola vez después de escribir las primeras entradas. Por tanto, en el constructor solo debe haber resolución de dependencias con inject() e inicialización de campos con constantes; toda inicialización que dependa de una entrada va en ngOnInit. La razón práctica es doble: leer una entrada en el constructor devuelve undefined, y un constructor con efectos secundarios convierte cualquier prueba unitaria en una prueba de integración.
¿Por qué no puedo inyectar una interfaz de TypeScript?
Porque las interfaces y los type son una construcción exclusiva del sistema de tipos: se borran al compilar y en el JavaScript resultante no queda ningún objeto. La inyección de dependencias necesita una llave que exista en tiempo de ejecución para buscar en un mapa, y una clase sí existe (es una función). Las dos soluciones correctas son crear un InjectionToken<MiInterfaz>, que es un objeto real con el tipo asociado, o usar una clase abstracta como token, que aporta a la vez el contrato para el compilador y la identidad en tiempo de ejecución.
¿Cuándo debo usar providedIn: 'root' y cuándo providers en el componente?
La pregunta que hay que responder es: ¿cuántas instancias quiero y cuánto deben vivir? Con providedIn: 'root' hay una sola instancia para toda la aplicación, que vive mientras viva la página: es lo correcto para estado global, acceso a datos y utilidades, y además es tree-shakable. Con providers en un componente hay una instancia por cada instancia del componente, que se destruye con él: es lo correcto para estado con ámbito, como el borrador de un formulario, el paso de un asistente o el estado de edición de cada tarjeta de una lista. Registrar el mismo servicio en los dos sitios es el error del duplicado descrito en 3.13.9.
¿Cuál es el orden de los hooks en un árbol padre-hijo y por qué es así?
El padre se inicializa hasta ngAfterContentChecked; entonces se ejecuta su plantilla, que crea e inicializa por completo al hijo (incluidos su ngAfterViewInit y su ngAfterViewChecked); y solo después se ejecutan el ngAfterViewInit y el ngAfterViewChecked del padre. La lógica es que la vista del padre no está completa hasta que sus hijos existen, y por eso las consultas de vista del padre no tienen valor antes. En la destrucción el orden es el contrario: primero el padre y luego los descendientes. El diagrama completo está en 3.5.1, con una advertencia importante: es un detalle de implementación estable, no un contrato sobre el que construir lógica.
¿Por qué mi ngOnChanges no se ejecuta cuando modifico el array que paso al hijo?
Porque Angular compara los valores de las entradas con ===, es decir, por referencia para los objetos. Si haces this.tareas.push(nueva), la referencia del array es la misma que antes, así que para el framework nada ha cambiado: no se llama a ngOnChanges, no se marca la vista OnPush y las señales tampoco se enteran. La solución no es forzar la detección de cambios sino trabajar de forma inmutable: this.tareas = [...this.tareas, nueva] o, con señales, this.tareas.update((ts) => [...ts, nueva]).
¿Cuál es la diferencia entre viewChild y contentChild?
viewChild busca en tu propia plantilla: lo que tú has escrito. contentChild busca en el contenido proyectado: lo que otro ha escrito entre las etiquetas de tu componente y ha aterrizado en un ng-content. La consecuencia práctica es de tiempos: el contenido lo crea el padre y por eso ya existe cuando tu componente se inicializa (ngAfterContentInit), mientras que tu vista se crea después (ngAfterViewInit). Además el contenido pertenece a la detección de cambios del padre, no a la tuya, y sus estilos llevan el atributo de ámbito del padre.
¿Debo usar inject() o parámetros del constructor?
inject() es la forma recomendada en código nuevo. Sus ventajas concretas: la herencia deja de ser un problema porque la clase base inyecta lo suyo sin obligar a las subclases a repetir parámetros y llamar a super(); permite escribir funciones reutilizables que inyectan, lo que hace posibles los guards, resolvers e interceptores funcionales; y la inferencia de tipos es mejor. Su única restricción, y es la que provoca todos los errores, es que solo puede llamarse en contexto de inyección: constructor, inicializadores de campo, factories o dentro de runInInjectionContext. Los parámetros del constructor siguen siendo perfectamente válidos y no hay ninguna urgencia por migrar código que funciona.
¿Qué es un pipe impuro y por qué se recomienda evitarlo?
Un pipe declarado con pure: false ejecuta su método transform en cada ciclo de detección de cambios que alcance la vista, en lugar de solo cuando cambian sus argumentos por referencia. Eso significa decenas o cientos de ejecuciones por segundo durante cualquier interacción. Tiene usos legítimos —AsyncPipe es impuro porque se suscribe a un flujo y emite valores por su cuenta—, pero usarlo «para que se actualice siempre» es cambiar un problema de diseño (estado mutado en el sitio) por un problema de rendimiento. Angular nunca incluyó filter ni orderBy integrados precisamente por este motivo, y lo documentó explícitamente.
¿Cuándo hago un componente y cuándo una directiva?
Si aportas estructura de DOM, es un componente. Si añades comportamiento a un elemento que ya existe, es una directiva. El criterio decisivo es la composición: en un mismo elemento pueden convivir varias directivas, mientras que dos componentes no pueden compartir el mismo anfitrión. Por eso el tanteo con «resaltar al pasar el ratón», «arrastrable» o «cerrar al pulsar fuera» siempre se resuelve con directivas: son comportamientos ortogonales que se combinan. Y si además necesitas decidir si un elemento existe o cuántas veces, es una directiva estructural o, mejor aún, el control flow integrado.
¿Para qué sirve multi: true?
Permite que varios proveedores contribuyan al mismo token, de modo que inject(TOKEN) devuelve un array con todas las contribuciones en lugar de una sola. Es el mecanismo con el que Angular implementa los interceptores HTTP, los validadores de formulario y los inicializadores de aplicación, y es el patrón adecuado para diseñar puntos de extensión: una funcionalidad puede añadir su validador de tareas sin que el código que los ejecuta se modifique, lo que es el principio de abierto/cerrado en la práctica. Restricción importante: todos los proveedores de un mismo token deben coincidir en el uso de multi; mezclarlos lanza un error en tiempo de ejecución.
¿Se ejecuta siempre ngOnDestroy?
Se ejecuta siempre que Angular destruya el componente: al navegar a otra ruta, al pasar a falso el @if que lo contiene, al eliminarse de un @for o al destruirse la aplicación. No se ejecuta de forma fiable cuando el usuario cierra la pestaña, recarga la página o el navegador mata el proceso, porque ahí no hay ciclo de vida que ejecutar: para ese caso hay que usar eventos del navegador como visibilitychange, con las limitaciones conocidas. En servicios provistos en un componente o en una ruta, ngOnDestroy también se ejecuta al destruirse ese inyector; en un servicio en root, solo al destruirse la aplicación.
¿Cómo comparto estado entre dos componentes hermanos?
Por orden de preferencia: si el estado es pequeño y ambos comparten un padre cercano, súbelo al padre, que lo baja por input y recibe los output (el flujo sigue siendo unidireccional y se razona fácilmente). Si el estado es más rico o lo comparten varios niveles, provee un servicio en el padre común con providers: []: ambos hermanos inyectan la misma instancia, aislada del resto de la aplicación y destruida con el padre. Si los componentes no comparten un ancestro razonable, usa un servicio con señales en root. Lo que no debe hacerse nunca es encadenar entradas y salidas a través de niveles intermedios que no tienen nada que ver con el dato.
¿Qué diferencia hay entre useClass y useExisting?
useClass le dice al inyector que construya una instancia de esa clase para ese token; si el mismo servicio ya estaba provisto con otro token, acabarás con dos instancias distintas, cada una con su estado, lo que suele ser un fallo difícil de ver. useExisting crea un alias: apunta al mismo objeto ya provisto bajo otro token. Es la receta correcta cuando quieres exponer una vista reducida del mismo servicio, por ejemplo un token de solo lectura para los componentes de presentación y la clase completa para el contenedor.
3.18 Ejercicios
3.1 · Componente de presentación. Escribe tf-etiqueta-tarea, que reciba una etiqueta ({ nombre, color }) con input.required y un booleano eliminable con transform: booleanAttribute, y emita eliminada con el nombre. Debe ser OnPush, no inyectar nada y llevar el color como variable CSS en el anfitrión.
3.2 · Enlace bidireccional. Convierte un selector de prioridad de tarea (1 a 5) en un componente que admita [(prioridad)] usando model(). Después reescríbelo con input más output siguiendo la convención x/xChange y comprueba que el padre no necesita ningún cambio.
3.3 · Pipe puro. Escribe tfIniciales, que convierta el nombre completo de un usuario de TaskFlow en sus iniciales («Ana Ruiz Gil» a «ARG»), con un argumento opcional para el número máximo de iniciales. Escribe tres pruebas unitarias sin TestBed.
3.4 · Ciclo de vida observable. Crea un par padre-hijo que registre en consola cada hook con un prefijo, y comprueba empíricamente el orden de 3.5.1. Añade un @if que destruya y recree el hijo y anota qué hooks se repiten y cuáles no.
3.5 · Directiva de atributo. Escribe [tfCerrarAlPulsarFuera], que emita una salida cuando se haga clic fuera del elemento anfitrión o se pulse Escape. Usa la propiedad host en lugar de los decoradores y asegúrate de que no queda ningún listener activo tras destruir la directiva.
3.6 · Componente compuesto. Implementa tf-acordeon con tf-panel-acordeon hijos, usando proyección y contentChildren. Añade una entrada multiple que permita tener varios paneles abiertos a la vez y navegación completa con teclado (flechas, Inicio, Fin) con los atributos ARIA correspondientes.
3.7 · Refactorización a contenedor y presentación. Parte de un componente que inyecta HttpClient, filtra, ordena y pinta tareas en 200 líneas. Divídelo en un contenedor, un componente de lista y un componente de fila, y demuestra que la lista se puede probar sin ningún doble de prueba.
3.8 · Directiva estructural con guard de tipos. Escribe *tfSiCargado="recurso() as datos; cargando: plantillaCarga; error: plantillaError", que instancie una de tres plantillas según el estado y estreche el tipo de datos con ngTemplateContextGuard y ngTemplateGuard_.
3.9 · Punto de extensión con multi. Diseña un token VALIDADOR_TAREA con multi: true y un servicio que ejecute todos los validadores registrados. Añade dos validadores desde módulos distintos de la aplicación sin modificar el servicio que los ejecuta, y explica qué principio SOLID estás aplicando.
3.10 · Estado con ámbito y jerarquía de inyectores. Implementa un árbol de proyectos anidados donde cada nodo provea un servicio de nivel y calcule su profundidad inyectando el del padre con skipSelf y optional. Añade un servicio en root con el total de nodos y explica por qué uno es global y el otro no.
3.11 · Estrategia intercambiable. Define AlmacenamientoBorradores como clase abstracta con dos implementaciones (memoria y localStorage) y un useFactory que elija según PLATFORM_ID. Escribe una prueba que sustituya la implementación por un doble y otra que verifique que en el renderizado en servidor no se toca window.
Solución comentada del ejercicio 3.1
import {
ChangeDetectionStrategy, Component, booleanAttribute, input, output,
} from '@angular/core';
export interface Etiqueta { readonly nombre: string; readonly color: string; }
@Component({
selector: 'tf-etiqueta-tarea',
changeDetection: ChangeDetectionStrategy.OnPush,
// 1) El color entra como variable CSS en el anfitrión: así el CSS
// no necesita [style.background] repetido en cada regla y el
// componente sigue siendo personalizable desde fuera.
host: {
'class': 'etiqueta',
'[style.--tf-etiqueta-color]': 'etiqueta().color',
// 2) Semántica: no es un botón, es texto con significado.
'role': 'listitem',
},
template: `
<span class="nombre">{{ etiqueta().nombre }}</span>
@if (eliminable()) {
<!-- 3) Elemento nativo + nombre accesible: el texto visible
es solo un icono, así que hace falta aria-label. -->
<button
type="button"
[attr.aria-label]="'Eliminar la etiqueta ' + etiqueta().nombre"
(click)="eliminada.emit(etiqueta().nombre)">
<span aria-hidden="true">×</span>
</button>
}
`,
styles: `
:host { display: inline-flex; align-items: center; gap: 4px;
background: var(--tf-etiqueta-color, #eee);
border-radius: 999px; padding: 2px 8px; }
`,
})
export class EtiquetaTareaComponent {
// 4) required: el compilador de plantillas obliga al padre a
// enlazarla. No hay estado "etiqueta a medio construir".
readonly etiqueta = input.required<Etiqueta>();
// 5) booleanAttribute permite las dos formas de uso:
// <tf-etiqueta-tarea eliminable> (atributo presente)
// <tf-etiqueta-tarea [eliminable]="puedeEditar()">
readonly eliminable = input(false, { transform: booleanAttribute });
// 6) La salida nombra lo OCURRIDO. El componente no sabe ni
// quiere saber si eliminar implica una llamada a la API.
readonly eliminada = output<string>();
}
// 7) Resultado: cero dependencias inyectadas, OnPush trivial y una
// prueba que se escribe en tres líneas asignando la entrada y
// comprobando el DOM y la salida emitida.
Solución comentada del ejercicio 3.9
import { InjectionToken, Injectable, inject } from '@angular/core';
// 1) El contrato de una extensión. Con clase abstracta en lugar de
// interfaz para poder usarla también como token si hiciera falta.
export interface ValidadorTarea {
validar(t: Tarea): string | null; // null = válida
}
// 2) El token es un ARRAY porque se proveerá con multi: true.
// El tipo lo refleja: readonly ValidadorTarea[].
export const VALIDADOR_TAREA = new InjectionToken<readonly ValidadorTarea[]>(
'ValidadorTarea',
// 3) factory por defecto: sin ningún validador registrado el
// servicio funciona igual y no lanza NullInjectorError.
{ providedIn: 'root', factory: () => [] },
);
// 4) El ejecutor NO conoce ningún validador concreto. Este archivo
// no se modificará nunca al añadir reglas nuevas: es el principio
// de abierto/cerrado (la O de SOLID) implementado por el inyector.
@Injectable({ providedIn: 'root' })
export class ValidacionTareasService {
private readonly validadores = inject(VALIDADOR_TAREA);
validar(t: Tarea): readonly string[] {
return this.validadores
.map((v) => v.validar(t))
.filter((e): e is string => e !== null);
}
}
// 5) Cada funcionalidad aporta sus reglas donde le corresponde,
// sin tocar el servicio anterior ni conocer a las demás.
@Injectable()
export class ValidadorTitulo implements ValidadorTarea {
validar(t: Tarea) { return t.titulo.trim().length >= 3 ? null : 'El título es demasiado corto'; }
}
@Injectable()
export class ValidadorFecha implements ValidadorTarea {
private readonly reloj = inject(RelojServicio); // sustituible en pruebas
validar(t: Tarea) {
if (!t.vence) { return null; }
return t.vence >= this.reloj.ahora() ? null : 'La fecha límite ya ha pasado';
}
}
// 6) Registro. Los dos proveedores se ACUMULAN en el array; con
// recetas normales, el último ganaría y perderíamos el primero.
bootstrapApplication(App, {
providers: [
{ provide: VALIDADOR_TAREA, useClass: ValidadorTitulo, multi: true },
{ provide: VALIDADOR_TAREA, useClass: ValidadorFecha, multi: true },
],
});
// 7) En una prueba, se registra solo el validador que interesa y se
// comprueba el ejecutor de forma aislada. Es exactamente el
// mecanismo con el que Angular implementa los interceptores HTTP.
3.19 Resumen del capítulo
- El componente es la unidad de interfaz, pero no la respuesta a todo: comportamiento sobre un elemento existente es una directiva, transformación de un valor es un pipe, y lógica o estado compartido es un servicio.
- Separar contenedores de componentes de presentación es la decisión de diseño con mejor relación entre esfuerzo y beneficio: hace las pruebas triviales y la reutilización posible.
- La comunicación tiene un mecanismo por relación:
inputhacia abajo,outputhacia arriba,modelpara valores editables, consultas para acciones imperativas y un servicio para todo lo lejano. Encadenar entradas por cinco niveles nunca es la respuesta. - Las entradas de señal son de solo lectura y participan en el grafo reactivo, lo que hace innecesario
ngOnChangesen la mayoría de los casos y elimina por construcción que un hijo escriba en su propia entrada. - El ciclo de vida tiene un orden estable: la inicialización baja por el árbol y la confirmación de vista sube. Cada hook tiene un uso legítimo y varios usos prohibidos; equivocarse cuesta peticiones duplicadas o
NG0100. - Vista es lo que yo escribo; contenido es lo que me dan. De esa distinción se derivan los tiempos de las consultas, la propiedad de la detección de cambios y el ámbito de los estilos.
- La inmutabilidad no es opcional: Angular compara referencias, así que mutar un objeto de entrada o un array es la causa número uno de «no se actualiza la vista».
- La encapsulación de estilos es una emulación con atributos. Los temas se hacen con variables CSS y
:host-context, nunca con::ng-deep, que es global y está desaconsejado. - La inyección de dependencias es inversión de control: la clase declara qué necesita y el contenedor decide la implementación. Eso es lo que permite sustituir en pruebas, configurar por entorno e intercambiar estrategias sin tocar el código que consume.
- Hay dos jerarquías de inyectores —entorno y elemento— y un algoritmo de resolución que sube desde el elemento hasta la plataforma. Casi todos los errores de inyección se diagnostican dibujando ese recorrido.
- No se puede inyectar una interfaz porque no existe en tiempo de ejecución: para eso están
InjectionTokeny las clases abstractas. inject()solo funciona en contexto de inyección. Recordar esa frase ahorra la mayoría de losNG0203.multi: truees el mecanismo de extensión del framework y el patrón que deberías usar cuando quieras que otros añadan comportamiento sin modificar tu código.- Accesibilidad y rendimiento se ganan en el diseño: elemento nativo antes que rol, foco gestionado explícitamente,
OnPushdesde el primer día y ningún cálculo en la plantilla.
3.20 Recursos adicionales
- Angular · Guía de componentes — el recorrido oficial completo: selectores, estilos, proyección, consultas y ciclo de vida.
- Angular · Ciclo de vida — la referencia canónica de cada hook y de los hooks de renderizado en tu versión concreta.
- Angular · Entradas basadas en señales —
input,input.required, alias y transformaciones. - Angular · Salidas —
output(),outputFromObservabley el enlace bidireccional conmodel. - Angular · Consultas de vista y de contenido —
viewChild,contentChildren,ready disponibilidad de resultados. - Angular · Directivas — directivas de atributo, estructurales, microsintaxis y
hostDirectives. - Angular · Inyección de dependencias — jerarquía de inyectores, recetas de proveedor, modificadores y tokens.
- Angular · Guía de estilo oficial — convenciones de nombres, organización de archivos y uso de la propiedad
host. - Angular · Accesibilidad — atributos ARIA en plantillas, gestión del foco y utilidades del CDK.
- Angular CDK —
FocusMonitor,LiveAnnouncer,ListKeyManagery desplazamiento virtual: componentes accesibles sin reinventarlos. - ARIA Authoring Practices Guide — el comportamiento de teclado y los roles esperados de cada patrón de interfaz (pestañas, acordeón, menú, diálogo).
- Angular · Referencia de errores — explicación oficial de cada código:
NG0100,NG0200,NG0201,NG0203y el resto.