Parte II · Angular

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.

CORE Angular MODERNO Tiempo de lectura: ~165 min Prerrequisitos: capítulos 1 y 2

3.1 Qué vas a poder hacer al terminar

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érminoQué es exactamente
ComponenteUna directiva con plantilla propia. Toda la maquinaria de las directivas (host, entradas, salidas, ciclo de vida) le aplica igual.
DirectivaUna 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.
PlantillaEl código fuente HTML con la sintaxis de Angular. El compilador la traduce a una función de instrucciones de renderizado.
Analogía: el plano y la casa

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.

src/app/tareas/tarea-item.component.ts
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ónPara 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.
Depende de la versión: 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-template con ng-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…UsaPor qué
Pintar una estructura de DOM con estado propioComponenteEs la única construcción con plantilla.
Añadir comportamiento a un elemento existenteDirectiva de atributoReutilizable en cualquier etiqueta y composable: varias directivas conviven en un mismo elemento; varios componentes, no.
Decidir si algo se pinta, o pintarlo n vecesDirectiva estructural o @if/@forManipula el ViewContainerRef, que es exactamente para eso.
Formatear un valor para mostrarloPipePuro y memoizado por Angular; se prueba con una llamada a transform.
Compartir estado o lógica de negocioServicioVive fuera del árbol de vistas y sobrevive a los componentes que lo usan.
Reutilizar un fragmento dentro del mismo componenteng-templateCoste cero: no crea clase, ni selector, ni frontera de entradas.
Transformar datos sin tocar el DOMFunción puraLo 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.

AspectoComponente de presentación (tonto)Contenedor (inteligente)
ResponsabilidadRenderizar lo que recibe y avisar de la intención del usuarioObtener datos, coordinar servicios y decidir qué se muestra
DatosEntran por input(). Nunca los busca por sí mismoLos pide a servicios inyectados
Servicios inyectadosNinguno, o solo de presentación (traducción, formato)Los que haga falta: estado, HTTP, router, diálogos
Comunicación hacia arribaoutput(): «el usuario ha pulsado completar»Llama a métodos del servicio: «marca la tarea 42 como hecha»
Conocimiento del dominioSolo la forma de los datos que pintaReglas de negocio de la pantalla y flujo de navegación
ReutilizaciónAlta: sirve en cualquier pantalla y en el catálogo de componentesBaja por definición: es específico de una pantalla
Cómo se pruebaLe das entradas y compruebas el DOM y las salidas emitidas. Sin dobles de pruebaCon servicios sustituidos por dobles mediante inyección de dependencias
Detección de cambiosOnPush trivial: todo depende de las entradasOnPush con señales del servicio
Analogía: la cocina y el camarero

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.

src/app/tareas/pagina-tareas.component.ts · CONTENEDOR
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.

src/app/tareas/tarea-lista.component.ts · PRESENTACIÓN
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.

tarea-lista.component.tsINCORRECTO
@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();
  }
}
tarea-lista.component.tsCORRECTO
@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');
Cuántos niveles y dónde poner la frontera Una regla práctica que funciona: un contenedor por ruta. La pantalla que el router monta es el contenedor; todo lo que hay debajo es presentación. Cuando una pantalla es muy grande puede haber un segundo nivel de contenedores (por ejemplo, un panel lateral con su propio estado), pero si aparece un tercero suele significar que la pantalla debería ser dos rutas.

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()

src/app/tareas/tarea-detalle.component.ts · el catálogo completo de entradas
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:

Cuidado: leer una entrada obligatoria demasiado pronto

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.

src/app/tareas/tarea-editor.component.ts
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);
  }
}
Una salida no es un canal de datos, es un aviso de intención El hijo dice «el usuario ha pedido completar esta tarea»; el padre decide si eso implica una llamada a la API, un diálogo de confirmación o nada. Cuando la salida se llama 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.

con model() · recomendado
@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" />
a mano · lo que hace model() por dentro
@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>();
El enlace bidireccional no siempre es un favor

[(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.

src/app/legado/tarea-antigua.component.ts
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ónMecanismo recomendadoNotas 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.

HookCuándo se ejecutaQué 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)
No construyas lógica sobre el orden exacto

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.

tarea-detalle.component.tsINCORRECTO
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();
  }
}
tarea-detalle.component.tsCORRECTO
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().

src/app/tareas/tarea-grafica.component.ts
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];
Con entradas de señal, 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.

src/app/tareas/tablero-tareas.component.ts
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.
Depende de la versión: los hooks de renderizado han cambiado de nombre

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.

RecursoLo limpia AngularCómo se libera si no
subscribe() manualNotakeUntilDestroyed() como último operador, o guardar la Subscription y llamar a unsubscribe()
setInterval / setTimeoutNoclearInterval / clearTimeout en la destrucción
addEventListener en window o documentNoremoveEventListener, o mejor @HostListener('window:…'), que sí gestiona Angular
ResizeObserver, IntersectionObserverNodisconnect()
WebSocket, EventSourceNoclose()
AsyncPipe, toSignal, output, effectNada que hacer: es el motivo para preferirlos
src/app/tareas/sincronizacion.service.ts · las tres formas de limpiar
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

src/app/ui/panel.component.ts
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.

ConsultaDisponible desdeCon contenido condicional
contentChild / contentChildrenngAfterContentInitLa señal se actualiza cuando el conjunto proyectado cambia.
viewChild / viewChildrenngAfterViewInitDevuelve undefined mientras el @if sea falso, y el elemento cuando sea verdadero.
Decorador con { static: true }ngOnInitNo se permite: solo funciona si el elemento no está dentro de ningún bloque condicional o repetido.
Como señal, leída en un effectEn cuanto tiene valorEl efecto se vuelve a ejecutar al aparecer o desaparecer el elemento. Es la forma robusta.
buscador.component.tsINCORRECTO
@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());
  }
}
buscador.component.tsCORRECTO
@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.
Una consulta de vista no es una excusa para saltarse el flujo de datos

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.

src/app/ui/tarjeta.component.ts · selectores múltiples y contenido por defecto
@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 {}
uso desde el padre
<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:

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.

src/app/ui/pestanas.component.ts · contenedor y elemento
@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>
La alternativa: inyectar el contenedor desde el hijo En lugar de que el contenedor consulte a sus hijos, cada hijo puede inyectar el contenedor (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.

EstrategiaCómo funcionaCuándo usarlaInconvenientes
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.
NoneLos 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.
ShadowDomUsa 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.
src/app/ui/tarjeta.component.css · selectores especiales
/* :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; }
Por qué ::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.

tema · INCORRECTOINCORRECTO
/* 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; }
tema · CORRECTOCORRECTO
/* 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.

src/app/ui/arrastrable.directive.ts · ejemplo completo
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.

src/app/ui/arrastrable.directive.ts · la misma directiva con la propiedad host
@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.
Nunca escribas en el DOM con 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):

src/app/seguridad/si-permiso.directive.ts · microsintaxis completa
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.

src/app/ui/si-existe.directive.ts
@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>
¿Escribir directivas estructurales todavía tiene sentido? Para condicionales y bucles, no: @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.

src/app/ui/boton-tarea.component.ts
@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 transformSolo si cambia la referencia de alguno de sus argumentosEn cada ciclo de detección de cambios que alcance la vista
CostePrácticamente nulo entre cambiosAlto: decenas o cientos de llamadas por segundo
InstanciaUna por uso en la plantilla, reutilizadaUna por uso, con estado propio permitido
Casos legítimosFormato, cálculo derivado, traducción, filtrado con entrada inmutableAsyncPipe (se suscribe y emite), pipes que dependen de algo que Angular no observa
Trampa habitualRecibir un array mutado: la referencia no cambia y el resultado se queda congeladoUsarlo «para que se actualice siempre»: es cambiar un fallo de diseño por un problema de rendimiento
src/app/tareas/vencimiento.pipe.ts
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.

lista.component.tsINCORRECTO
@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(); }
}
lista.component.tsCORRECTO
@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(); }
}
Regla de decisión

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.

MotivoQué pasa si la lógica vive en el componente
Vida distintaEl 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ónLa 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.
PruebasPara 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.
SustituibilidadSin inyección no puedes cambiar la implementación en pruebas ni por entorno: el new es un acoplamiento en tiempo de compilación.
Responsabilidad únicaLa 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:

src/app/tareas/ · una responsabilidad por servicio
// 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 { /* … */ } }
La prueba del algodón Si puedes explicar qué hace un servicio en una frase sin usar la palabra «y», está bien diseñado. 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).

sin inyecciónINCORRECTO
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.
con inyecciónCORRECTO
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 }],
//    });
Analogía: el enchufe

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 instanciasCuá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 componenteUna 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 rutaUna 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 bootstrapApplicationEquivale al inyector raíz.Configuración de la aplicación: provideHttpClient, provideRouter, tokens de entorno.
src/app/tareas/tareas.routes.ts · ámbito por ruta y por componente
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.

src/app/app.config.ts · las cinco recetas
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 },
  ],
};
Todos los proveedores de un token deben coincidir en 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.

src/app/core/tokens.ts
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.

AspectoConstructorinject()
HerenciaLa subclase debe repetir los parámetros y llamar a super(...)La clase base inyecta lo suyo; la subclase no se entera
Uso en funcionesImposiblePermite escribir funciones reutilizables que inyectan: guards, resolvers, interceptores funcionales
Inferencia de tiposBuenaMejor: inject(TOKEN) devuelve el tipo del token sin anotarlo
ModificadoresDecoradores de parámetro @Optional(), @Self()Objeto de opciones: { optional: true, skipSelf: true }
Dónde se puede llamarDonde se instancia la claseSolo en contexto de inyección: ahí está toda la dificultad
src/app/core/contexto-inyeccion.ts
// 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í.

ModificadorEfectoCaso 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.
src/app/ui/nodo-arbol.component.ts · skipSelf y optional combinados
@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

ErrorCausa realCómo se arregla
NG0201
NullInjectorError: 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.
NG0200
dependencia 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.
  • OnPush por 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.
  • readonly en todos los campos que no se reasignan, y protected para 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.querySelector o innerHTML: rompe el renderizado en servidor y la seguridad.
  • viewChild para pasar datos en lugar de entradas: acopla el padre al interior del hijo.
  • Suscripciones sin cancelar y suscripciones anidadas.
  • ::ng-deep para 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.
  • any en 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.

plantillaINCORRECTO
<!-- 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>
plantillaCORRECTO
<!-- 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>
Accesibilidad en el código TypeScript Los enlaces del anfitrión son el lugar natural para la semántica de un componente reutilizable: 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.

Mide antes de optimizar

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 errorCausaSolución
NG0201: No provider for XEl 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 contextSe 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: ExpressionChangedAfterItHasBeenCheckedEl 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 @forExpresió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 elementEl 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 destruirseSuscripció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 proyectadoEncapsulació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 ejecutaErrata 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 campoMé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 servidorUso 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

Nivel 1 · básico

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.

Nivel 1 · básico

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.

Nivel 1 · básico

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.

Nivel 2 · intermedio

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.

Nivel 2 · intermedio

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.

Nivel 2 · intermedio

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.

Nivel 2 · intermedio

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.

Nivel 3 · avanzado

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_.

Nivel 3 · avanzado

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.

Nivel 3 · avanzado

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.

Nivel 3 · avanzado

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
src/app/tareas/etiqueta-tarea.component.ts
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">&times;</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
src/app/tareas/validacion.ts · punto de extensión con multi
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: input hacia abajo, output hacia arriba, model para 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 ngOnChanges en 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 InjectionToken y las clases abstractas.
  • inject() solo funciona en contexto de inyección. Recordar esa frase ahorra la mayoría de los NG0203.
  • multi: true es 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, OnPush desde el primer día y ningún cálculo en la plantilla.

3.20 Recursos adicionales

Siguiente paso Ya sabes cómo se construye y se conecta cada pieza de una aplicación Angular. Lo que queda por entender es cuándo se refresca lo que has construido: el capítulo 4 entra en las señales, RxJS y la detección de cambios, que es donde se resuelven tanto los fallos de «esto no se actualiza» como los de rendimiento.