Parte II · Angular

4. Reactividad: Signals, RxJS y Change Detection

Una aplicación web no es más que una función que transforma estado en píxeles. El problema difícil no es pintar: es saber cuándo hay que volver a pintar. Este capítulo explica los tres mecanismos con los que Angular responde a esa pregunta —el grafo de señales, los flujos de RxJS y el ciclo de detección de cambios—, cómo se relacionan entre sí y por qué la mayoría de los errores «mi vista no se actualiza» y «mi aplicación va lenta» se resuelven entendiendo estas tres piezas. Es el capítulo más importante de la parte de Angular y el que más se pregunta en una entrevista técnica.

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

4.1 Qué vas a poder hacer al terminar

4.2 El problema de fondo: qué significa que una interfaz sea reactiva

Empecemos por el enunciado más honesto del problema. Tienes un estado en memoria —un array de tareas, un filtro de texto, un booleano de «cargando»— y una plantilla que describe cómo se ve ese estado. Cuando el estado cambia, el DOM debe cambiar. La dificultad es que JavaScript no avisa: asignar this.titulo = 'nuevo' es una escritura en una propiedad de un objeto y nada en el lenguaje notifica a nadie de que ha ocurrido.

Por tanto, todo framework de interfaz tiene que resolver una única pregunta: ¿cómo me entero de que el estado ha cambiado, y cómo sé qué parte concreta del DOM depende de lo que ha cambiado? Las respuestas posibles se agrupan en tres familias.

FamiliaCómo detecta el cambioCosteEjemplos
Comprobación sucia
(dirty checking)
No lo detecta: recorre periódicamente todas las expresiones enlazadas y compara el valor actual con el anterior Proporcional al tamaño de la aplicación, no al del cambio. Simple de programar, caro de ejecutar AngularJS (digest), Angular con Zone.js
Flujos push
(observables)
El productor empuja valores hacia quien se ha suscrito explícitamente Preciso y componible, pero el desarrollador gestiona a mano suscripciones, cancelación y multidifusión RxJS, streams de Dart, Kotlin Flow
Grafo de dependencias
(señales)
La lectura de un valor se registra automáticamente: el framework sabe quién depende de quién Proporcional a lo que realmente ha cambiado. Requiere que toda lectura pase por la primitiva Angular Signals, SolidJS, Vue 3 ref, Svelte 5 runes, MobX, Preact Signals
Analogía: la hoja de cálculo

Un grafo de dependencias es exactamente una hoja de cálculo. En A1 escribes 10 y en B1 la fórmula =A1*2. Nadie ha programado la sincronización: al escribir la fórmula, la hoja registró que B1 lee A1. Cuando cambias A1, la hoja no recalcula el documento entero; recalcula B1 y lo que dependa de B1.

Traducido a Angular: signal es una celda con un valor (A1), computed es una celda con una fórmula (B1) y effect es esa macro que configuras para que, cada vez que cambie el resultado, escriba una línea en un fichero de registro. La comprobación sucia, en cambio, sería recalcular todas las celdas del libro cada vez que tocas una tecla: funciona, y en una hoja pequeña ni lo notas, pero no escala.

4.2.1 Dos propiedades que separan una solución buena de una mala

Cualquiera puede escribir un sistema reactivo que funcione en un ejemplo de dos variables. Lo que distingue a una implementación seria son dos garantías:

Como veremos en 4.4.7, Angular consigue ambas cosas con una estrategia híbrida: empuja la invalidación y tira del valor (push the notification, pull the value). Los cambios se propagan marcando nodos como potencialmente sucios —lo cual es baratísimo— y el valor real solo se calcula cuando alguien lo lee.

4.3 Historia: del digest cycle al modo zoneless

Cronología de la reactividad en Angular

2010 · AngularJS y el digest cycle. El primer Angular popularizó el data binding bidireccional. Su motor era la comprobación sucia: cada expresión de la plantilla registraba un $watch con el valor de la última comprobación. Al llamar a $apply(), el framework ejecutaba $digest, que recorría todos los watchers comparando valor actual y anterior. Como un watcher podía modificar algo que otro observaba, el bucle se repetía hasta que nada cambiaba, con un máximo de 10 vueltas; superarlo lanzaba el legendario 10 $digest() iterations reached. La regla práctica de la época era «no pases de 2.000 watchers por pantalla».

2016 · Angular 2 y Zone.js. La reescritura eliminó el $scope y el bucle iterativo. En su lugar se adoptó Zone.js, una librería que parchea las APIs asíncronas del navegador (temporizadores, eventos, XHR, promesas) para saber cuándo termina cualquier tarea asíncrona. Cuando la cola de microtareas de la zona de Angular queda vacía, el framework ejecuta un recorrido del árbol de componentes de arriba abajo y una sola vez (más una segunda pasada de verificación en desarrollo). Aparecieron entonces la estrategia OnPush y el ExpressionChangedAfterItHasBeenChecked­Error.

2016–2022 · La era RxJS. Con Angular 2 llegó RxJS al núcleo del framework: HttpClient, el router, los formularios reactivos y EventEmitter son observables. El patrón dominante fue el «servicio con BehaviorSubject privado más asObservable() público», consumido en plantilla con AsyncPipe. Potente, pero con una curva de aprendizaje notable y una categoría entera de fallos propios (fugas por no cancelar suscripciones, shareReplay mal configurado, anidamiento de subscribe).

Mayo de 2023 · Angular v16: señales en developer preview. Se publican signal(), computed() y effect(), junto con toSignal()/toObservable(). El objetivo declarado por el equipo: reactividad granular y, a medio plazo, poder prescindir de Zone.js.

Noviembre de 2023 · Angular v17: señales estables. El núcleo de la API (signal, computed, effect) se declara estable. En el camino desaparece mutate(), que existió en la preview de la v16 y se retiró precisamente porque invitaba a mutar objetos sin cambiar la referencia. Llega también el nuevo control flow (@if, @for) y @defer.

2024 · v17.1 a v18: entradas y consultas basadas en señales. input(), model(), output(), viewChild() y contentChild() como señales (ver capítulo 3). En la v18 aparece el modo zoneless en fase experimental, con provideExperimentalZonelessChangeDetection().

2024–2025 · v19 y v20: la capa asíncrona. Se incorporan linkedSignal() y resource()/rxResource() en developer preview, se replantea la planificación de los efectos y el modo zoneless alcanza la estabilidad como provideZonelessChangeDetection(). En paralelo se estandariza la propuesta de signals para TC39 (JavaScript), en la que participa el propio equipo de Angular junto con los de otros frameworks.

Qué significa esto para ti, en una frase Angular ha pasado de «pregunto a toda la aplicación si algo ha cambiado» a «el estado me dice exactamente qué vista tiene que refrescarse». Las tres tecnologías conviven: señales para el estado de la interfaz, RxJS para los flujos con dimensión temporal y detección de cambios como el motor que traduce todo eso en escrituras del DOM. Este capítulo trata las tres y, sobre todo, sus fronteras.

4.4 Signals a fondo

Una señal es un contenedor de un valor que notifica a sus consumidores cuando ese valor cambia. Nada más y nada menos. Las tres propiedades que la definen:

4.4.1 signal(): el estado escribible

contador.component.ts · la API completa de una señal escribible
import { Component, signal } from '@angular/core';
@Component({
  selector: 'app-contador',
  template: `<p>Valor: {{ contador() }}</p><button (click)="incrementar()">+1</button>`,
})
export class ContadorComponent {
  // 1) CREAR. El tipo se infiere: WritableSignal<number>
  protected readonly contador = signal(0);
  // Con tipo explícito cuando el inicial no basta para inferirlo
  protected readonly tareas = signal<Tarea[]>([]);
  protected readonly seleccionada = signal<Tarea | null>(null);
  incrementar(): void {
    // 2) ESCRIBIR: set() reemplaza el valor
    this.contador.set(this.contador() + 1);
    // 3) ESCRIBIR: update() lo deriva del anterior. Es la forma preferida
    //    cuando el nuevo valor depende del actual: evita leer y escribir por separado.
    this.contador.update((v) => v + 1);
  }
  // 4) LEER: la llamada como función devuelve el valor actual
  esPar(): boolean {
    return this.contador() % 2 === 0;
  }
}

asReadonly(): encapsulación. Un servicio no debe exponer una señal escribible al exterior; si lo hace, cualquier componente puede alterar el estado global saltándose la lógica de negocio. La convención es una señal privada con guion bajo y su versión de solo lectura como API pública. Es el mismo principio que el BehaviorSubject privado con asObservable() de la era RxJS.

sesion.service.tsINCORRECTO
@Injectable({ providedIn: 'root' })
export class SesionService {
  // Señal escribible expuesta al mundo: cualquiera puede
  // hacer sesion.usuario.set(null) y provocar un cierre
  // de sesión fantasma imposible de rastrear.
  readonly usuario = signal<Usuario | null>(null);
}
sesion.service.tsCORRECTO
@Injectable({ providedIn: 'root' })
export class SesionService {
  private readonly _usuario = signal<Usuario | null>(null);
  // API pública de solo lectura: Signal<Usuario | null>
  readonly usuario = this._usuario.asReadonly();
  readonly autenticado = computed(() => this._usuario() !== null);
  // Las escrituras pasan SIEMPRE por métodos con nombre de intención
  iniciarSesion(u: Usuario): void { this._usuario.set(u); }
  cerrarSesion(): void { this._usuario.set(null); }
}

La opción equal: cuándo se considera que ha cambiado. Por defecto Angular compara con Object.is, es decir, por identidad. Si el valor nuevo es igual al anterior, la señal no notifica y no se recalcula nada aguas abajo. Esto es una optimización muy relevante: asignar el mismo valor cien veces no produce ni un solo repintado.

equal.ts · igualdad personalizada
import { signal } from '@angular/core';
// Caso 1: el servidor devuelve un array nuevo en cada sondeo, pero con
// el mismo contenido. Sin 'equal', cada respuesta cambia la referencia
// y dispara todo el árbol de computed y de vistas que dependan de ella.
const filas = signal<Fila[]>([], {
  equal: (a, b) => a.length === b.length && a.every((x, i) => x.id === b[i].id),
});
// Caso 2: valores de coma flotante con tolerancia
const temperatura = signal(20.0, {
  equal: (a, b) => Math.abs(a - b) < 0.05,
});
// AVISO: 'equal' se ejecuta en CADA escritura. Si la comparación es más
// cara que el trabajo que evita, has empeorado el rendimiento.
// Con objetos grandes, una comparación estructural profunda casi nunca
// compensa; suele ser mejor normalizar el estado (ver 4.9).
Las señales comparan referencias, no contenido Si haces this.tareas().push(nueva), el array se modifica pero la referencia es la misma: Object.is(anterior, actual) devuelve true y la señal no notifica absolutamente nada. La vista se queda congelada y no hay ningún error en consola. Es, con diferencia, el fallo número uno con señales; lo tratamos en detalle en 4.5.

4.4.2 computed(): el estado derivado

Un computed es una señal de solo lectura cuyo valor se calcula a partir de otras señales. Tiene tres propiedades que hay que entender bien porque de ellas depende todo lo demás:

carrito.component.ts · derivar en cadena
import { Component, computed, signal } from '@angular/core';
@Component({ /* ... */ })
export class CarritoComponent {
  readonly lineas = signal<Linea[]>([]);
  readonly cupon = signal<Cupon | null>(null);
  readonly pais = signal('ES');
  // Cada computed depende solo de lo que lee. Angular lo descubre solo.
  readonly subtotal = computed(() =>
    this.lineas().reduce((t, l) => t + l.precio * l.cantidad, 0),
  );
  readonly descuento = computed(() => {
    const c = this.cupon();
    if (!c) return 0;                       // si no hay cupón, NO lee subtotal()
    return c.tipo === 'porcentaje'
      ? this.subtotal() * c.valor / 100
      : Math.min(c.valor, this.subtotal());
  });
  readonly baseImponible = computed(() => this.subtotal() - this.descuento());
  readonly iva     = computed(() => this.baseImponible() * tipoIva(this.pais()));
  readonly total   = computed(() => this.baseImponible() + this.iva());
  // Derivados de presentación: también son estado derivado
  readonly vacio       = computed(() => this.lineas().length === 0);
  readonly numArticulos = computed(() =>
    this.lineas().reduce((n, l) => n + l.cantidad, 0),
  );
}
Regla de diseño: una sola fuente de verdad En el ejemplo anterior el estado real son tres señales (lineas, cupon, pais); todo lo demás es consecuencia. Nunca guardes en una señal escribible algo que puedas derivar: en cuanto exista un camino para actualizar una cosa y olvidar la otra, se desincronizarán. Si te descubres escribiendo this.total.set(...) dentro de un effect, ese total debería haber sido un computed.

Por qué un computed no debe tener efectos secundarios. No es una recomendación estética, es una consecuencia directa de la memoización y la pereza. La función de cálculo se ejecuta un número de veces impredecible: cero si nadie lo lee, una si el valor está en caché, varias si sus dependencias cambian. Si dentro escribes en localStorage, lanzas una petición HTTP o registras una métrica, ese efecto ocurrirá un número arbitrario de veces. Además, Angular impide escribir señales dentro de un computed: obtendrás un error en tiempo de ejecución (NG0600).

informe.component.tsINCORRECTO
readonly filtradas = computed(() => {
  const r = this.tareas().filter((t) => t.hecha);
  // 1) Efecto secundario: se ejecuta un número
  //    impredecible de veces, o ninguna.
  this.analitica.registrar('filtro', r.length);
  // 2) Escritura de señal dentro de un computed:
  //    NG0600 en tiempo de ejecución.
  this.contador.set(r.length);
  return r;
});
informe.component.tsCORRECTO
// El computed es una función PURA: entradas -> salida.
readonly filtradas = computed(() =>
  this.tareas().filter((t) => t.hecha),
);
// Lo derivado se deriva...
readonly contador = computed(() => this.filtradas().length);
// ...y el efecto externo se declara donde corresponde.
constructor() {
  effect(() => {
    this.analitica.registrar('filtro', this.contador());
  });
}

4.4.3 effect(): sincronizar con el mundo exterior

Un effect es una función que Angular vuelve a ejecutar cuando cambia alguna de las señales que leyó. Se ejecuta siempre al menos una vez tras su creación y su cometido es exactamente uno: propagar el estado reactivo hacia algo que no es reactivo.

Uso legítimo de effectEjemplo concreto
Persistir en almacenamiento del navegadorGuardar el tema o el idioma en localStorage cuando cambian
Interoperar con una librería no reactivaActualizar un mapa de Leaflet, un gráfico de Chart.js o un editor de código
Manipular el DOM fuera de la plantillaFijar el foco, sincronizar document.title, ajustar atributos de accesibilidad
Registro y analíticaEnviar un evento cuando el usuario cambia de pestaña o de filtro
Depuracióneffect(() => console.log('estado', this.estado()))
preferencias.service.ts · efectos con limpieza
import { Injectable, effect, signal, untracked } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PreferenciasService {
  readonly tema = signal<'claro' | 'oscuro'>(leerTemaInicial());
  readonly consultaGuardada = signal('');
  constructor() {
    // 1) Efecto simple: estado reactivo -> API del navegador. Se ejecuta una
    //    vez al crearse y luego cada vez que cambia 'tema'.
    effect(() => {
      document.documentElement.dataset['theme'] = this.tema();
      localStorage.setItem('tema', this.tema());
    });
    // 2) Efecto con limpieza. onCleanup se ejecuta ANTES de la siguiente
    //    ejecución y también al destruir el efecto: ahí se cancelan
    //    temporizadores, escuchas y suscripciones.
    effect((onCleanup) => {
      const consulta = this.consultaGuardada();
      const id = setTimeout(() => this.guardarEnServidor(consulta), 800);
      onCleanup(() => clearTimeout(id));   // cancela el guardado anterior
    });
    // 3) Efecto que NO debe reaccionar a todo lo que lee: untracked() lee
    //    sin crear dependencia (ver 4.4.6).
    effect(() => {
      const t = this.tema();                       // SÍ es dependencia
      const u = untracked(() => this.usuario());   // NO es dependencia
      this.analitica.enviar('cambio_tema', { tema: t, usuario: u?.id });
    });
  }
}

Contexto de inyección y ciclo de vida

effect() debe crearse dentro de un contexto de inyección: el constructor de un componente, directiva o servicio, o un inicializador de campo. La razón es que el efecto necesita conocer el Injector para registrar su destrucción automática cuando muere el componente o el servicio que lo creó. Fuera de ese contexto obtendrás el error NG0203. Si de verdad necesitas crearlo más tarde —dentro de un ngOnInit o de un manejador de eventos—, pasa el inyector explícitamente.

efecto-fuera-de-contexto.ts
export class PanelComponent implements OnInit {
  private readonly injector = inject(Injector);
  readonly filtro = signal('');
  ngOnInit(): void {
    // Sin la opción 'injector' esto lanzaría NG0203:
    // "effect() can only be used within an injection context".
    effect(() => console.log('filtro', this.filtro()), { injector: this.injector });
  }
  // Alternativa equivalente y más idiomática:
  //   runInInjectionContext(this.injector, () => effect(...));
}
La escritura de señales dentro de efectos ha cambiado entre versiones

En Angular v16 a v18, escribir una señal dentro de un effect estaba prohibido por defecto y producía el error NG0600; había que optar explícitamente con effect(fn, { allowSignalWrites: true }). A partir de Angular v19 la escritura pasó a estar permitida por defecto y la opción allowSignalWrites quedó obsoleta (sin efecto). En esa misma versión cambió también la planificación de los efectos: dejaron de ejecutarse como microtareas independientes para integrarse en el ciclo de detección de cambios.

Antes de copiar código de un artículo, comprueba la versión con ng version y consulta la firma real en node_modules/@angular/core/index.d.ts. Lo que no ha cambiado es el consejo de diseño: que se pueda escribir señales en un efecto no significa que debas hacerlo.

Por qué effect no sirve para derivar estado

Este es el antipatrón más extendido entre quienes vienen del useEffect de React. Derivar estado con un efecto tiene cuatro problemas concretos, y ninguno es teórico:

  1. Introduce un estado intermedio inconsistente. Entre que cambia el origen y se ejecuta el efecto, la señal destino contiene un valor viejo que la vista puede llegar a pintar.
  2. Pierde la pereza. El efecto se ejecuta aunque nadie lea el resultado; un computed no.
  3. Invita a los bucles. Si el efecto lee A y escribe B, y otro lee B y escribe A, tienes un ciclo infinito de detección de cambios que se manifiesta como una pestaña congelada.
  4. Rompe la trazabilidad. Con computed, la relación «esto se calcula a partir de aquello» está escrita en una sola expresión. Con efectos, hay que reconstruirla leyendo todo el fichero.
busqueda.component.tsINCORRECTO
readonly texto = signal('');
readonly tareas = signal<Tarea[]>([]);
readonly visibles = signal<Tarea[]>([]);   // estado duplicado
constructor() {
  // Estado derivado con un efecto: un ciclo de retraso,
  // sin memoización y con riesgo de bucle.
  effect(() => {
    const t = this.texto().toLowerCase();
    this.visibles.set(
      this.tareas().filter((x) => x.titulo.toLowerCase().includes(t)),
    );
  });
}
busqueda.component.tsCORRECTO
readonly texto = signal('');
readonly tareas = signal<Tarea[]>([]);
// Una única fuente de verdad, cálculo perezoso y memoizado,
// siempre coherente y sin posibilidad de bucle.
readonly visibles = computed(() => {
  const t = this.texto().toLowerCase();
  return this.tareas().filter((x) =>
    x.titulo.toLowerCase().includes(t),
  );
});
Prueba del algodón antes de escribir un effect Pregúntate: «¿el resultado de esto es un valor que otra parte de mi aplicación va a leer?». Si la respuesta es sí, necesitas un computed, no un effect. Un efecto correcto termina en algo que no es estado de Angular: el DOM, el almacenamiento, la red, la consola o una librería externa.

4.4.4 linkedSignal(): estado escribible que se reinicia con su fuente

Existe un caso intermedio muy frecuente que ni signal ni computed resuelven bien: un valor que el usuario puede modificar libremente, pero que debe reiniciarse cuando cambia el contexto del que procede. El ejemplo canónico es una selección: el usuario elige una fila de una tabla, pero si la tabla se recarga con datos distintos, esa selección deja de tener sentido.

linkedSignal() es exactamente esa primitiva: una señal escribible cuyo valor se recalcula automáticamente cuando cambia su fuente.

selector-tarea.component.ts · las dos formas de linkedSignal
import { Component, computed, input, linkedSignal, signal } from '@angular/core';
@Component({ /* ... */ })
export class SelectorTareaComponent {
  readonly tareas = signal<Tarea[]>([]);
  // FORMA CORTA: el valor se recalcula cada vez que cambia cualquier señal
  // leída en la función. Sigue siendo escribible con set()/update().
  readonly seleccionada = linkedSignal(() => this.tareas()[0] ?? null);
  // FORMA COMPLETA: separa la fuente del cálculo y da acceso al valor
  // anterior, lo que permite CONSERVAR la selección si sigue existiendo.
  readonly seleccionadaEstable = linkedSignal<Tarea[], Tarea | null>({
    source: this.tareas,
    computation: (tareasActuales, previo) => {
      // 'previo' es undefined la primera vez; después es
      // { source: valor anterior de la fuente, value: valor anterior de la señal }
      const anterior = previo?.value;
      const sigue = anterior && tareasActuales.some((t) => t.id === anterior.id);
      return sigue ? anterior : (tareasActuales[0] ?? null);
    },
  });
  // Un cambio en this.tareas() descarta la escritura manual y vuelve a
  // aplicar la 'computation'. Ese es justo el comportamiento deseado.
  elegir(t: Tarea): void { this.seleccionada.set(t); }
}
NecesidadPrimitivaMotivo
Valor que solo cambia el usuariosignal()Nadie más lo determina
Valor totalmente determinado por otroscomputed()Solo lectura, memoizado y perezoso
Valor que cambia el usuario pero que un cambio de contexto invalidalinkedSignal()Escribible y con reinicio declarativo
Valor que llega de una fuente asíncronaresource()Gestiona carga, error y cancelación
Disponibilidad linkedSignal() se incorporó en Angular v19 en developer preview. Si trabajas con la v17 o la v18, el sustituto correcto es una señal escribible más un effect que la reinicie, asumiendo sus limitaciones. Comprueba siempre la versión instalada antes de usarlo.

4.4.5 resource() y rxResource(): carga asíncrona declarativa

El patrón manual de carga de datos que todos hemos escrito mil veces —una señal para el valor, otra para «cargando», otra para el error, y no olvidarse de cancelar la petición anterior— es tan repetitivo que Angular lo ha convertido en una primitiva. Un resource ata una petición reactiva (unos parámetros derivados de señales) a un cargador asíncrono, y expone el resultado como señales.

detalle-usuario.component.ts · resource con fetch
import { Component, input, resource } from '@angular/core';
@Component({
  selector: 'app-detalle-usuario',
  template: `
    @if (usuario.isLoading()) { <app-spinner /> }
    @else if (usuario.error()) {
      <p class="error">No se pudo cargar: {{ usuario.error() }}</p>
      <button (click)="usuario.reload()">Reintentar</button>
    } @else if (usuario.hasValue()) { <h2>{{ usuario.value()!.nombre }}</h2> }
  `,
})
export class DetalleUsuarioComponent {
  readonly usuarioId = input.required<number>();
  readonly usuario = resource({
    // 1) Parámetros reactivos: cuando cambian, se relanza la carga
    //    y se ABORTA automáticamente la petición anterior.
    params: () => ({ id: this.usuarioId() }),
    // 2) Cargador: recibe los parámetros y un AbortSignal.
    loader: async ({ params, abortSignal }) => {
      const r = await fetch(`/api/usuarios/${params.id}`, { signal: abortSignal });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return (await r.json()) as Usuario;
    },
  });
}

La superficie pública de un recurso son señales, lo que lo hace componible con el resto del sistema:

MiembroTipoPara qué sirve
value()Señal escribible del valorEl dato cargado. Es escribible para permitir actualizaciones optimistas locales
status()Señal de estadoEstado del ciclo: inactivo, cargando, recargando, resuelto, con error o modificado localmente
error()Señal de errorEl error lanzado por el cargador, o undefined
isLoading()Señal booleanaAtajo para pintar un indicador de carga
hasValue()Señal booleanaEstrecha el tipo: dentro de la rama, value() no es undefined
reload()MétodoFuerza una recarga con los mismos parámetros (botón «Reintentar»)
tareas.component.ts · rxResource con HttpClient
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxResource } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class TareasComponent {
  private readonly http = inject(HttpClient);
  readonly filtro = signal('');
  readonly pagina = signal(1);
  // rxResource acepta un Observable en lugar de una promesa. Es la puerta
  // natural para HttpClient: la cancelación de la petición anterior se
  // hace por des-suscripción, sin necesidad de AbortController.
  readonly tareas = rxResource({
    params: () => ({ q: this.filtro(), page: this.pagina() }),
    stream: ({ params }) =>
      this.http.get<Pagina<Tarea>>('/api/tareas', {
        params: { q: params.q, page: params.page },
      }),
  });
}
Atención: la API de resource ha cambiado entre versiones

Se introdujo en Angular v19 en fase experimental y su firma se ha renombrado desde entonces. Los dos cambios que más código rompen:

  • La propiedad de entrada se llamaba request en la v19 y pasó a llamarse params; dentro del cargador, el argumento request pasó a ser params.
  • En rxResource, la propiedad loader dio paso a stream para reflejar que puede emitir varias veces, y status() pasó de devolver un enumerado a devolver cadenas literales.

Cómo verificarlo en tu proyecto, sin fiarte de ningún tutorial: ejecuta ng version, abre node_modules/@angular/core/index.d.ts y busca ResourceOptions, o pulsa F12 sobre resource en el editor para ir a la definición de tipos. La firma que ves ahí es la única verdad. Al estar en developer preview, evita usarlo en el núcleo de una aplicación crítica sin haber previsto el coste de una futura migración.

4.4.6 untracked(), toSignal() y toObservable()

utilidades.ts
import { computed, effect, signal, untracked } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';

// ---------- untracked: leer SIN crear dependencia ----------
// Sin untracked, este efecto se reejecutaría también al cambiar el usuario,
// enviando eventos de analítica duplicados que nadie pidió.
effect(() => {
  const pagina = this.paginaActual();                   // dependencia real
  const usuario = untracked(() => this.usuario());      // solo lectura
  this.analitica.pagina(pagina, usuario?.id);
});
// También sirve para invocar métodos que leen muchas señales cuando solo
// te interesa reaccionar a una de ellas:
effect(() => {
  this.idSeleccionado();                                 // única dependencia
  untracked(() => this.recalcularTodoElPanel());
});
// ---------- toSignal: Observable -> Signal ----------
// Se suscribe al crearse y se da de baja solo cuando muere el contexto de
// inyección. Necesita saber qué valor tiene ANTES de la primera emisión.
readonly parametros = toSignal(this.route.queryParams, { initialValue: {} });
// ---------- toObservable: Signal -> Observable ----------
// Emite el valor actual y cada cambio posterior. Internamente usa un effect,
// así que también requiere contexto de inyección.
readonly filtro$ = toObservable(this.filtro);

La sección 4.7 desarrolla la interoperabilidad completa, con todas las opciones de toSignal y los criterios para decidir en qué dirección conviene convertir.

4.4.7 Arquitectura interna: el grafo productor/consumidor

Entender el mecanismo interno no es un lujo académico: es lo que te permite predecir cuántas veces se ejecuta tu código y explicar comportamientos que de otro modo parecen mágicos.

Angular mantiene un grafo dirigido bidireccional. Cada nodo es un productor (algo que se puede leer: signal, computed), un consumidor (algo que lee: computed, effect, una vista) o ambas cosas a la vez, como es el caso de computed. Las aristas se crean solas: cuando se ejecuta la función de un consumidor, Angular guarda una referencia al «consumidor activo» en una variable global; cada lectura de señal durante esa ejecución registra la arista en los dos sentidos (el consumidor apunta a sus productores para poder revalidarlos, y el productor apunta a sus consumidores para poder notificarlos).

  ESTADO INICIAL — nadie ha leído todavía: nada está calculado

     precio(10) ─────┐
                     ├──► subtotal [SIN CALCULAR]  ──► total [SIN CALCULAR] ──► Vista
     cantidad(2) ────┘                                      ▲
                                                            │
     iva(0.21) ─────────────────────────────────────────────┘

  PRIMERA LECTURA desde la plantilla — se calcula bajo demanda y se memoiza

     precio(10) ─────┐
                     ├──► subtotal [20] v1 ──► total [24.2] v1 ──► Vista {24.2}
     cantidad(2) ────┘                             ▲
     iva(0.21) ────────────────────────────────────┘

     Aristas creadas hacia arriba (consumidor -> productor) y hacia abajo
     (productor -> consumidor). El grafo ya sabe quién depende de quién.

  ESCRITURA: precio.set(12)   →  FASE 1: NOTIFICAR (barata, sin cálculos)

     precio(12) v2 ──► subtotal [SUCIO] ──► total [SUCIO] ──► Vista MARCADA
                       (conserva 20)        (conserva 24.2)

     No se ha ejecutado ni una sola función de cálculo. Solo se han puesto
     marcas y se ha avisado al planificador de detección de cambios.

  LECTURA POSTERIOR (durante el refresco)  →  FASE 2: TIRAR DEL VALOR

     total() ─┬─► ¿estoy sucio? sí ─► pide subtotal()
              │                        └─► ¿estoy sucio? sí ─► recalcula: 24
              │                             ¿24 === 20? NO ─► versión v2
              └─► como subtotal cambió, recalcula total: 29.04

     Si subtotal hubiera dado 20 otra vez (por ejemplo, precio 10 -> 10.0),
     la comprobación de igualdad habría CORTADO la propagación aquí y
     'total' no se habría recalculado. A esto se le llama poda por igualdad.

Los mecanismos concretos que hacen que esto funcione:

Analogía: la cadena de mando Cuando cambia una señal no se hace un censo (comprobación sucia): se transmite una orden por la cadena de mando —«todo lo que dependa de mí queda en revisión»—, que llega solo a quien corresponde y es instantánea. El trabajo real, el recuento, únicamente lo hace quien necesita el dato cuando lo necesita.

4.4.8 Reglas de oro

Si el valor es…UsaSeñal de que te has equivocado
Estado propio, que solo cambia por acción del usuario o de una respuesta signal() Lo escribes desde un effect a partir de otras señales
Derivado por completo de otro estado computed() Tienes que acordarte de actualizarlo en tres sitios distintos
Escribible pero dependiente de un contexto que lo invalida linkedSignal() Un effect que hace set(valorPorDefecto)
Asíncrono, con carga, error y cancelación resource() / rxResource() Tres señales manuales datos, cargando y error que se desincronizan
Un efecto sobre el exterior: DOM, almacenamiento, red, librería effect() El efecto termina llamando a set() sobre otra señal
Un flujo en el tiempo: eventos, sondeo, reintentos, websockets RxJS (ver 4.6) Reimplementas debounce con setTimeout dentro de un efecto

4.5 Errores clásicos con señales

4.5.1 Mutar en lugar de reemplazar

Es el error número uno y merece su propia sección porque no produce ningún mensaje de error: simplemente la interfaz deja de responder. Recuerda del capítulo 1 que los objetos se manejan por referencia; la comparación por defecto de una señal es Object.is, es decir, comparación de referencias.

tareas.store.tsINCORRECTO
private readonly _tareas = signal<Tarea[]>([]);
anadir(t: Tarea): void {
  // La MISMA referencia: Object.is(antes, después) === true.
  // No se notifica a nadie. La vista no cambia. No hay error.
  this._tareas().push(t);
}
marcarHecha(id: number): void {
  const t = this._tareas().find((x) => x.id === id);
  if (t) t.hecha = true;             // muta el objeto interno: invisible
}
ordenar(): void {
  this._tareas().sort((a, b) => a.orden - b.orden);   // sort muta in situ
}
renombrar(nombre: string): void {
  this._filtro().nombre = nombre;    // muta un objeto anidado
}
tareas.store.tsCORRECTO
private readonly _tareas = signal<Tarea[]>([]);
anadir(t: Tarea): void {
  // Nueva referencia de array: la señal notifica.
  this._tareas.update((ts) => [...ts, t]);
}
marcarHecha(id: number): void {
  // Nueva referencia de array Y del objeto modificado.
  this._tareas.update((ts) =>
    ts.map((x) => (x.id === id ? { ...x, hecha: true } : x)),
  );
}
ordenar(): void {
  // toSorted() (ES2023) devuelve un array nuevo; si no está
  // disponible: [...ts].sort(...)
  this._tareas.update((ts) => ts.toSorted((a, b) => a.orden - b.orden));
}
renombrar(nombre: string): void {
  this._filtro.update((f) => ({ ...f, nombre }));
}
Cuidado con los métodos de array que mutan

Mutan el original: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.

Devuelven uno nuevo: map, filter, slice, concat, flat, y las versiones inmutables de ES2023 toSorted, toReversed, toSpliced y with.

Una defensa barata y eficaz es tipar el estado como readonly Tarea[]: el compilador rechazará push y sort antes de que llegues a ejecutarlo.

4.5.2 Los otros cuatro errores habituales

errores-signals.tsINCORRECTO
export class PanelComponent {
  readonly filtro = signal('');
  readonly total = signal(0);
  readonly items = signal<Item[]>([]);
  // 1) Efecto creado fuera del contexto de inyección: NG0203.
  ngOnInit(): void {
    effect(() => console.log(this.filtro()));
  }
  // 2) Se copia el VALOR en una constante: se pierde el vínculo
  //    reactivo. 'valor' queda congelado en este instante.
  leerMal(): void {
    const valor = this.total();
    setTimeout(() => console.log(valor), 1000);  // valor viejo
  }
  // 3) Dos efectos que se escriben mutuamente: bucle infinito.
  constructor() {
    effect(() => this.total.set(this.items().length));
    effect(() => this.items.update((i) => [...i.slice(0, this.total())]));
  }
  // 4) computed que llama a un método con efectos secundarios.
  readonly resumen = computed(() => this.construirYGuardar(this.items()));
}
errores-signals.tsCORRECTO
export class PanelComponent {
  private readonly injector = inject(Injector);
  readonly filtro = signal('');
  readonly items = signal<Item[]>([]);
  // 1) En el campo o en el constructor (hay contexto de inyección),
  //    o pasando el injector si de verdad hace falta más tarde.
  private readonly registro = effect(() => console.log(this.filtro()));
  // 2) Pasa la SEÑAL, no su valor: quien la lea obtendrá el actual.
  leerBien(): void {
    setTimeout(() => console.log(this.total()), 1000);
  }
  // 3) Sin ciclo: 'total' es una consecuencia, no un estado.
  readonly total = computed(() => this.items().length);
  // 4) computed puro; el guardado es un efecto explícito.
  readonly resumen = computed(() => construirResumen(this.items()));
  constructor() {
    effect(() => this.almacen.guardar(this.resumen()));
  }
}
SíntomaCausa realCómo confirmarlo
La vista no se actualiza y no hay errores Mutación sin cambio de referencia Añade effect(() => console.log(this.estado())): si no se dispara, la señal no ha notificado
Un computed devuelve siempre lo mismo Lee un valor capturado en una variable, no la señal Comprueba que dentro del cálculo hay paréntesis en cada lectura
La pestaña se congela Ciclo de efectos que se escriben entre sí Comenta los efectos uno a uno; sustituye por computed
NG0203 effect, inject o toSignal fuera del contexto de inyección Muévelo al constructor o pasa { injector }
NG0600 Escritura de señal dentro de un computed (o de un effect en v16–v18 sin la opción correspondiente) Convierte la derivación en computed y deja el efecto para lo externo

4.6 RxJS: valores que ocurren en el tiempo

Las señales resuelven muy bien el estado síncrono de la interfaz. Lo que no resuelven es el tiempo: esperar 300 ms desde la última pulsación, cancelar la petición en curso al llegar otra, reintentar con esperas crecientes, sondear cada diez segundos, fusionar dos flujos de eventos. Para eso está RxJS, que sigue siendo parte del núcleo de Angular: HttpClient, el router, los formularios reactivos y los outputs de componentes son observables.

4.6.1 Qué es exactamente un Observable

Un Observable es una función que, al ejecutarse, produce cero, uno o muchos valores a lo largo del tiempo y termina con una notificación de finalización o de error. Sus cuatro propiedades definitorias:

anatomia.ts · qué hay dentro de un Observable
import { Observable } from 'rxjs';
// El contrato: next* (error | complete)?
// Tras 'error' o 'complete' NO puede llegar nada más. Nunca.
const temporizador$ = new Observable<number>((subscriber) => {
  let n = 0;
  const id = setInterval(() => {
    subscriber.next(n++);                 // 0..n valores
    if (n > 4) subscriber.complete();     // notificación de fin
  }, 1000);
  // La función devuelta es la LÓGICA DE CANCELACIÓN: se ejecuta al
  // desuscribirse, al completar y al fallar. Aquí es donde se evitan
  // las fugas de memoria.
  return () => clearInterval(id);
});
// Nada ha ocurrido todavía. La suscripción es la que arranca el trabajo.
const sub = temporizador$.subscribe({
  next: (v) => console.log('valor', v),
  error: (e) => console.error('error', e),
  complete: () => console.log('completado'),
});
sub.unsubscribe();   // ejecuta clearInterval

4.6.2 Promise vs Observable vs Signal

AspectoPromiseObservableSignal
Número de valoresExactamente 10, 1 o muchosSiempre 1 valor actual
PerezaAnsiosa: se ejecuta al crearsePerezosa: se ejecuta al suscribirsePerezosa en el cálculo derivado
CancelaciónNo (se puede ignorar el resultado, no detener el trabajo)Sí, con unsubscribe()No aplica: no hay trabajo en curso
Síncrono / asíncronoSiempre asíncrona (microtarea)Puede ser cualquiera de los dosSiempre síncrona
Valor inicialNo hay hasta que resuelvePuede no haberlo nuncaObligatorio: siempre hay algo que leer
Composiciónthen, Promise.allMás de cien operadorescomputed
MultidifusiónSí por naturaleza (una sola ejecución)No por defecto: cada suscripción reejecutaSí: un valor compartido
Gestión de errorescatchcatchError, retryNo tiene concepto de error
Uso recomendadoOperación puntual sin cancelación (arranque, migraciones)Eventos, tiempo, HTTP con cancelación o reintento, websocketsEstado de la interfaz y su derivación
En la plantillaVía AsyncPipeAsyncPipeLectura directa x()
Una frase que resume la relación Una señal responde a «¿qué vale ahora?»; un observable responde a «¿qué ha ido pasando?». No compiten: el flujo entra por RxJS, se procesa con operadores y termina convertido en señal para que la plantilla lo lea sin suscripciones.

4.6.3 Operadores de creación

creacion.ts
import { of, from, fromEvent, interval, timer, defer, EMPTY, throwError } from 'rxjs';
// of: emite los argumentos SÍNCRONAMENTE y completa. Útil en tests y como
// valor de reserva dentro de catchError.
of(1, 2, 3);                       // 1, 2, 3, complete
// from: convierte cualquier iterable, promesa o array en observable.
from([1, 2, 3]);
from(fetch('/api/tareas'));        // promesa -> observable (NO cancelable de verdad)
// fromEvent: eventos del DOM como flujo. Registra el escuchador al suscribirse
// y lo quita al desuscribirse: sin fugas.
fromEvent<KeyboardEvent>(document, 'keydown');
// interval: cada N ms, empezando en N. timer: espera inicial y periodo opcional.
// Para sondeo, timer es casi siempre el correcto porque dispara de inmediato.
interval(1000);                    // 0 a los 1000 ms, 1 a los 2000 ms...
timer(0, 10_000);                  // 0 ya, 1 a los 10 s, 2 a los 20 s...
// defer: crea el observable EN EL MOMENTO DE SUSCRIBIRSE. Así se captura un
// valor "de ahora" en lugar de "de cuando se escribió el código".
const ahora$ = defer(() => of(Date.now()));
// EMPTY: completa sin emitir (NEVER, en cambio, no emite ni completa jamás).
// Es el sustituto habitual en catchError para tragarse el error y cerrar.
EMPTY;
// throwError: en RxJS 7 y posteriores requiere una FÁBRICA, no el error
// directo, para que la pila de llamadas se capture al suscribirse.
throwError(() => new Error('fallo controlado'));

4.6.4 Frío, caliente y Subject

Un observable frío crea su productor por cada suscripción: dos suscriptores a http.get() lanzan dos peticiones. Un observable caliente tiene un productor único y compartido: todos los suscriptores ven las mismas emisiones. Esta distinción explica la mayor parte de los comportamientos sorprendentes de RxJS.

TipoComportamientoCaso de uso típico
SubjectMultidifusión pura, sin valor actual. Un suscriptor tardío no recibe lo ya emitidoEventos: «se ha guardado», «cerrar diálogo»
BehaviorSubjectRequiere valor inicial y guarda el último. Todo suscriptor lo recibe al instanteEstado compartido (hoy sustituido por señales)
ReplaySubjectReemite los N últimos valores (opcionalmente con ventana temporal)Historial de notificaciones, caché de las últimas emisiones
AsyncSubjectSolo emite el último valor, y únicamente al completarRaro; resultados de una operación que solo interesan al final
multicast.ts · convertir frío en caliente
import { shareReplay, share } from 'rxjs';
// PROBLEMA: cada suscriptor dispara su propia petición.
readonly config$ = this.http.get<Config>('/api/config');
// tres | async en la plantilla = tres peticiones HTTP
// SOLUCIÓN: multidifusión con caché del último valor.
readonly config$ = this.http.get<Config>('/api/config').pipe(
  shareReplay({ bufferSize: 1, refCount: true }),
);
// share() sin buffer: multidifusión sin recordar el último valor.
readonly eventos$ = this.socket.mensajes$.pipe(share());
La trampa de shareReplay(1) sin refCount

La forma antigua shareReplay(1) equivale a refCount: false. Significa que la suscripción a la fuente nunca se cancela, aunque todos los consumidores se hayan desuscrito. Con un http.get es tolerable —la respuesta llega y se acabó—, pero con un interval, un websocket o un fromEvent tienes una fuga garantizada: el sondeo sigue vivo para siempre, incluso después de destruir el componente.

Regla práctica: usa shareReplay({ bufferSize: 1, refCount: true }) salvo que deliberadamente quieras una caché permanente para toda la sesión, como la configuración de la aplicación.

estado.service.tsINCORRECTO
@Injectable({ providedIn: 'root' })
export class FiltroService {
  // Subject expuesto: cualquiera puede llamar a next()
  // y nadie sabe quién emitió qué.
  readonly filtro$ = new BehaviorSubject('');
  // Además, obliga a todo consumidor a suscribirse,
  // a gestionar el valor inicial y a cancelar.
}
// En el componente:
ngOnInit() {
  this.svc.filtro$.subscribe((f) => (this.filtro = f));
  // sin unsubscribe: fuga mientras viva el servicio raíz
}
estado.service.tsCORRECTO
@Injectable({ providedIn: 'root' })
export class FiltroService {
  // Estado síncrono compartido: esto es EXACTAMENTE
  // el caso de uso de una señal.
  private readonly _filtro = signal('');
  readonly filtro = this._filtro.asReadonly();
  cambiar(v: string): void { this._filtro.set(v); }
  // Y si algún consumidor necesita el flujo temporal:
  readonly filtro$ = toObservable(this.filtro);
}
// En el componente: nada que suscribir ni que cancelar.
// La plantilla lee this.svc.filtro() directamente.

4.6.5 Operadores por familias

Transformación

transformacion.ts
import { map, scan, reduce, pairwise, bufferTime, toArray } from 'rxjs';
// map: transforma cada valor. El 90 % del uso diario.
this.http.get<RespuestaApi>('/api/tareas').pipe(
  map((r) => r.data.map(aModeloDeDominio)),
);
// scan: como reduce, pero emite el acumulado EN CADA valor.
// Es el operador para "estado que se construye poco a poco".
clics$.pipe(scan((total) => total + 1, 0));      // 1, 2, 3, 4...
// reduce: emite un único valor al COMPLETAR. Inútil en flujos infinitos.
lote$.pipe(reduce((acc, x) => acc + x.importe, 0));
// pairwise: emite [anterior, actual]. Ideal para detectar transiciones.
estado$.pipe(pairwise(), filter(([a, b]) => a !== 'error' && b === 'error'));
// NOTA: pluck('a', 'b') quedó obsoleto en RxJS 7 y se eliminó en la 8.
// Sustituto directo, además con mejor tipado:
usuario$.pipe(map((u) => u.direccion?.ciudad));

Aplanado: los cuatro operadores que hay que dominar

Un operador de aplanado se usa cuando cada valor del flujo externo da lugar a otro observable (típicamente una petición HTTP) y hay que decidir qué hacer si llega un valor nuevo mientras el anterior sigue en curso. La respuesta a esa pregunta es toda la diferencia entre los cuatro.

  Fuente: A en t=1, B en t=3, C en t=10.
  Cada letra lanza una petición que tarda 5 unidades y emite un valor (a, b, c).

  t            0    5    10   15   20
               |    |    |    |    |
  fuente       -A-B------C--------------

  switchMap    --------b------c---------   A se CANCELA al llegar B
  mergeMap     ------a-b------c---------   los tres corren EN PARALELO
  concatMap    ------a----b----c--------   COLA: cada uno espera al anterior
  exhaustMap   ------a--------c---------   B se IGNORA: A seguía en curso

  Lectura del diagrama:
    switchMap  → solo importa el último. A nunca llega a emitir.
    mergeMap   → todos llegan, pero el ORDEN de llegada no está garantizado.
    concatMap  → todos llegan y EN ORDEN, a costa de latencia acumulada.
    exhaustMap → mientras haya uno en curso, los nuevos se descartan.
OperadorQué hace con el interior anteriorOrden garantizadoÚsalo paraNo lo uses para
switchMap Lo cancela Sí (solo llega el último) Buscadores, cambios de ruta, cualquier lectura donde solo interese el resultado más reciente Escrituras: cancelarías un POST que quizá ya se ejecutó en el servidor
mergeMap Lo deja correr en paralelo No Operaciones independientes donde el orden da igual y se quiere máximo rendimiento Cuando el orden importa o hay riesgo de saturar el servidor (usa concurrent)
concatMap Lo encola Sí, estricto Autoguardado, secuencias de escrituras, migraciones, cualquier cosa transaccional Flujos rápidos: la cola crece sin límite y la latencia se acumula
exhaustMap Lo mantiene e ignora los nuevos Botones de envío, inicio de sesión, «cargar más»: antirrebote real Cuando no puedes perder ninguna entrada del usuario
aplanado.tsINCORRECTO
// 1) Suscripción anidada: el "callback hell" de RxJS.
//    Sin cancelación, sin manejo de errores unificado
//    y con condiciones de carrera garantizadas.
this.ruta.params.subscribe((p) => {
  this.api.tarea(p['id']).subscribe((t) => {
    this.api.comentarios(t.id).subscribe((c) => {
      this.comentarios = c;
    });
  });
});
// 2) mergeMap en un buscador: llegan respuestas
//    desordenadas y la lista parpadea con resultados
//    de consultas antiguas.
this.texto$.pipe(
  mergeMap((q) => this.api.buscar(q)),
).subscribe((r) => (this.resultados = r));
// 3) switchMap para guardar: si el usuario pulsa dos
//    veces rápido, el primer POST se cancela... pero
//    puede haberse ejecutado ya en el servidor.
this.guardar$.pipe(
  switchMap((d) => this.api.crear(d)),
);
aplanado.tsCORRECTO
// 1) Encadenado plano: una sola suscripción,
//    cancelación automática y errores en un solo sitio.
this.ruta.params.pipe(
  map((p) => Number(p['id'])),
  switchMap((id) => this.api.tarea(id)),
  switchMap((t) => this.api.comentarios(t.id)),
  takeUntilDestroyed(this.destroyRef),
).subscribe((c) => this.comentarios.set(c));
// 2) switchMap en lecturas: solo cuenta la última
//    consulta; las anteriores se cancelan de verdad.
this.texto$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((q) => this.api.buscar(q)),
);
// 3) exhaustMap para escrituras iniciadas por el
//    usuario: el segundo clic se ignora hasta que
//    termine el primero. Antirrebote sin flags.
this.guardar$.pipe(
  exhaustMap((d) => this.api.crear(d)),
);

Filtrado

filtrado.ts
import {
  filter, debounceTime, throttleTime, auditTime, distinctUntilChanged,
  distinctUntilKeyChanged, take, takeUntil, takeWhile, first, skip,
} from 'rxjs';
// filter: deja pasar lo que cumple el predicado. Con un type guard, estrecha el tipo.
eventos$.pipe(filter((e): e is EventoCreado => e.tipo === 'creado'));
// debounceTime: espera a que haya SILENCIO durante N ms y emite el último valor.
// Es el operador del buscador: no dispara mientras el usuario teclea.
texto$.pipe(debounceTime(300));
// throttleTime: emite el primero y luego ignora durante N ms (scroll, resize).
// auditTime: al contrario, ignora N ms y emite el ÚLTIMO del intervalo.
scroll$.pipe(throttleTime(100));
raton$.pipe(auditTime(16));    // ~1 vez por fotograma
// distinctUntilChanged: ignora repeticiones CONSECUTIVAS. Sin él, un debounce
// deja pasar 'abc' -> borrar -> 'abc' como dos consultas idénticas.
texto$.pipe(distinctUntilChanged());
usuario$.pipe(distinctUntilKeyChanged('id'));
objeto$.pipe(distinctUntilChanged((a, b) => a.id === b.id && a.v === b.v));
// take(n): n valores y completa, lo que cancela la fuente. first(): el primero,
// pero LANZA EmptyError si el flujo completa sin emitir; take(1) es más seguro.
notificaciones$.pipe(take(5));
// takeUntil: corta cuando otro observable emite. Base del patrón clásico de
// cancelación, hoy reemplazado por takeUntilDestroyed en Angular.
sondeo$.pipe(takeUntil(this.destruido$));
// takeWhile: emite mientras se cumpla la condición; con inclusive: true emite
// también el valor que la rompe.
progreso$.pipe(takeWhile((p) => p < 100, true));
// skip: descarta los n primeros. Para ignorar el valor inicial de un
// BehaviorSubject o el primer valueChanges de un formulario.
form.valueChanges.pipe(skip(1));

Combinación

combinacion.ts
import {
  combineLatest, forkJoin, merge, concat, zip, race,
  withLatestFrom, startWith, pairwise,
} from 'rxjs';
// combineLatest: emite cada vez que emite CUALQUIERA, con los últimos valores de
// todos. No emite hasta que TODOS hayan emitido al menos una vez. Es el
// "combinador de filtros" por excelencia.
combineLatest([this.texto$, this.categoria$, this.pagina$]).pipe(
  map(([q, cat, page]) => ({ q, cat, page })),
  switchMap((params) => this.api.buscar(params)),
);
// forkJoin: espera a que TODOS completen y emite el ÚLTIMO valor de cada uno.
// El Promise.all de RxJS: si uno falla, falla todo. Cuidado, con un observable
// que no completa, forkJoin no emite nunca.
forkJoin({
  usuario: this.api.usuario(id),
  permisos: this.api.permisos(id),
}).subscribe(({ usuario, permisos }) => { /* ... */ });
// merge: intercala emisiones de varias fuentes según llegan.
merge(this.creado$, this.editado$, this.borrado$).pipe(
  switchMap(() => this.api.recargar()),
);
// concat: reproduce las fuentes EN ORDEN; la siguiente empieza al completar
// la anterior. zip: empareja por índice, y si una fuente es más lenta la otra
// se acumula en memoria (poco frecuente en aplicaciones de gestión).
concat(this.cache$, this.red$);
zip(peticiones$, respuestas$);
// withLatestFrom: emite SOLO cuando emite el flujo principal, añadiendo el
// último valor de los secundarios. A diferencia de combineLatest, aquí manda
// el de la izquierda.
this.guardar$.pipe(
  withLatestFrom(this.formulario$, this.usuario$),
  switchMap(([, form, usuario]) => this.api.guardar(form, usuario.id)),
);
// startWith: inyecta un valor inicial. Imprescindible para que combineLatest
// arranque si alguna fuente aún no ha emitido.
this.categoria$.pipe(startWith('todas'));
// race: se queda con la primera fuente que emita y descarta el resto.
race(this.cacheRapida$, this.red$);

Utilidad y control de errores

errores-rxjs.ts
import { tap, delay, finalize, timeout, catchError, retry, EMPTY, of, timer, throwError } from 'rxjs';
// tap: efectos secundarios sin alterar el flujo (registro, indicadores). Acepta
// el objeto observador completo, que es más expresivo. finalize: se ejecuta
// SIEMPRE al terminar (éxito, error o cancelación); el sitio para el spinner.
this.api.buscar(q).pipe(
  tap({
    subscribe: () => this.cargando.set(true),
    error: (e) => this.logger.error(e),
  }),
  finalize(() => this.cargando.set(false)),
);
// timeout: falla si no llega un valor en el plazo indicado.
this.api.lento().pipe(
  timeout({ each: 5000 }),
  catchError((e) => (e.name === 'TimeoutError'
    ? of(RESPUESTA_POR_DEFECTO)
    : throwError(() => e))),
);
// catchError: intercepta el error y DEBE devolver un observable.
// Tres estrategias, según lo que quieras que ocurra:
this.api.tareas().pipe(
  catchError(() => of([])),        // 1) valor de reserva: el flujo continúa
  // catchError(() => EMPTY),      // 2) tragar y cerrar en silencio
  // catchError((e) => throwError(() => new Error('...', { cause: e }))), // 3) reenvolver
);
// retry con configuración (RxJS 7.4+): el retroceso exponencial correcto.
this.api.tareas().pipe(
  retry({
    count: 3,
    delay: (error, intento) => {
      // No reintentes errores del cliente: un 404 o un 400 no mejoran solos.
      if (error.status >= 400 && error.status < 500) throw error;
      const espera = Math.min(1000 * 2 ** (intento - 1), 10_000);
      return timer(espera + Math.random() * 200);   // backoff + jitter
    },
    resetOnSuccess: true,
  }),
);
// NOTA DE VERSIÓN: retryWhen quedó obsoleto en RxJS 7.8 y se eliminó en
// la 8. El objeto de configuración de retry lo sustituye por completo y
// es más legible. Si ves retryWhen en un tutorial, es código antiguo.
Un error termina el observable para siempre Tras una notificación de error, el observable está muerto: no volverá a emitir jamás. Este es el motivo por el que un formulario deja de reaccionar «desde que falló una vez». Si el flujo debe sobrevivir a los fallos, el catchError tiene que estar dentro del operador de aplanado, no fuera: así solo muere el observable interno.
buscador.tsINCORRECTO
// El catchError está en el flujo EXTERNO: al primer
// fallo de la API, el buscador queda inutilizado para
// el resto de la vida del componente.
this.texto$.pipe(
  debounceTime(300),
  switchMap((q) => this.api.buscar(q)),
  catchError(() => of([])),
).subscribe((r) => this.resultados.set(r));
buscador.tsCORRECTO
// El catchError protege solo la petición interna.
// El flujo externo nunca ve el error y sigue vivo.
this.texto$.pipe(
  debounceTime(300),
  switchMap((q) =>
    this.api.buscar(q).pipe(
      catchError(() => of([])),
    ),
  ),
).subscribe((r) => this.resultados.set(r));

4.6.6 Gestión de la suscripción: por qué las fugas son un problema real

Una suscripción viva mantiene una referencia al closure del subscribe, y ese closure captura this, es decir, el componente entero con su árbol de vistas y sus datos. Si el componente se destruye pero la suscripción sigue activa, el recolector de basura no puede liberar nada. En una aplicación con navegación intensiva, el resultado es memoria que crece sin parar y manejadores que se ejecutan sobre componentes que ya no están en pantalla, provocando errores incomprensibles.

TécnicaCuándo usarlaRiesgo
AsyncPipe en la plantillaSiempre que el valor solo se use para pintarNinguno: Angular cancela al destruir la vista
toSignal()Cuando además quieres derivar con computedNinguno si se crea en contexto de inyección
takeUntilDestroyed()Cuando necesitas un subscribe de verdad (efectos secundarios)Debe ser el último operador del pipe
take(1) / first()Flujos que completan solos, como un http.getNulo, aunque no protege de una respuesta tardía
unsubscribe() manualCasos límite fuera del ciclo de vida de AngularAlto: es fácil olvidarlo en una rama de código
suscripciones.component.ts
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-panel',
  // 1) Lo mejor: sin suscripción explícita en ninguna parte
  template: `@for (t of tareas(); track t.id) { <p>{{ t.titulo }}</p> }`,
})
export class PanelComponent {
  private readonly destroyRef = inject(DestroyRef);
  // toSignal se suscribe y se da de baja solo. No hay nada que recordar.
  readonly tareas = toSignal(this.api.tareas$, { initialValue: [] as Tarea[] });
  constructor() {
    // 2) Cuando hace falta un efecto secundario real:
    //    takeUntilDestroyed usa el DestroyRef del contexto actual.
    //    En el constructor puede omitirse el argumento.
    this.eventos.notificaciones$
      .pipe(takeUntilDestroyed())
      .subscribe((n) => this.toast.mostrar(n));
  }
  ngOnInit(): void {
    // Fuera del constructor hay que pasar el DestroyRef explícitamente.
    this.socket.mensajes$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((m) => this.procesar(m));
  }
}
takeUntilDestroyed siempre al final del pipe Si lo colocas antes de un switchMap, cortas el flujo externo pero el observable interno puede quedar suscrito. La regla es sencilla y no tiene excepciones prácticas: es el último operador antes de subscribe. Lo mismo se aplicaba al takeUntil(this.destruido$) clásico.

4.6.7 Patrones de producción

patrones.ts · los seis flujos que vas a escribir una y otra vez
// 1) BUSCADOR: debounce + descarte de repetidos + cancelación
readonly texto = signal('');
readonly resultados = toSignal(
  toObservable(this.texto).pipe(
    map((t) => t.trim()),
    debounceTime(300),            // espera a que deje de teclear
    distinctUntilChanged(),       // no repitas la misma consulta
    filter((t) => t.length === 0 || t.length >= 2),
    switchMap((t) =>              // cancela la petición anterior
      t.length === 0
        ? of([])
        : this.api.buscar(t).pipe(catchError(() => of([]))),
    ),
  ),
  { initialValue: [] as Resultado[] },
);

// 2) AUTOGUARDADO: nada se pierde y todo va en orden
this.form.valueChanges.pipe(
  debounceTime(1000),
  distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
  concatMap((valor) =>             // COLA: respeta el orden de escritura
    this.api.guardarBorrador(valor).pipe(
      catchError(() => of({ error: true })),
    ),
  ),
  takeUntilDestroyed(),
).subscribe();

// 3) BOTÓN DE GUARDAR: antirrebote sin variables booleanas
private readonly enviar$ = new Subject<Formulario>();
readonly enviando = signal(false);
constructor() {
  this.enviar$.pipe(
    exhaustMap((datos) => {        // ignora clics mientras haya uno en curso
      this.enviando.set(true);
      return this.api.crear(datos).pipe(
        finalize(() => this.enviando.set(false)),
        catchError((e) => { this.error.set(e.message); return EMPTY; }),
      );
    }),
    takeUntilDestroyed(),
  ).subscribe((creado) => this.router.navigate(['/tareas', creado.id]));
}

// 4) POLLING: sondeo que se detiene cuando la pestaña no está visible
readonly estado = toSignal(
  fromEvent(document, 'visibilitychange').pipe(
    startWith(null),
    switchMap(() =>
      document.hidden
        ? EMPTY                                     // pestaña oculta: no sondees
        : timer(0, 10_000).pipe(                    // ya, y luego cada 10 s
            switchMap(() => this.api.estado().pipe(catchError(() => EMPTY))),
          ),
    ),
  ),
);

// 5) REINTENTO CON RETROCESO EXPONENCIAL (ver 4.6.5)
this.api.critico().pipe(
  retry({
    count: 4,
    delay: (err, n) => timer(Math.min(500 * 2 ** (n - 1), 8000)),
  }),
);

// 6) CANCELAR AL NAVEGAR: switchMap sobre los parámetros de ruta
readonly detalle = toSignal(
  this.route.paramMap.pipe(
    map((p) => Number(p.get('id'))),
    filter((id) => Number.isFinite(id)),
    distinctUntilChanged(),
    switchMap((id) => this.api.detalle(id)),   // al cambiar de id, aborta
  ),
);

4.7 Interoperabilidad: señales ↔ RxJS

El paquete @angular/core/rxjs-interop es el puente entre los dos mundos. Sus dos funciones principales son toSignal y toObservable, y conviene entender bien sus opciones porque tienen consecuencias de tipado y de tiempo.

interop.ts · toSignal y sus opciones
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// 1) initialValue: el tipo resultante es Signal<T>.
//    Es la opción recomendada en el 90 % de los casos.
readonly tareas = toSignal(this.api.tareas$, { initialValue: [] as Tarea[] });
// 2) Sin opciones: el tipo es Signal<T | undefined>.
//    Correcto y honesto: antes de la primera emisión NO hay valor.
//    Obliga a contemplar el caso undefined en la plantilla, que es
//    exactamente lo que debe ocurrir.
readonly usuario = toSignal(this.api.usuario$);   // Signal<Usuario | undefined>
// 3) requireSync: el tipo es Signal<T> SIN valor inicial, porque afirmas
//    que la fuente emite de forma síncrona al suscribirse (BehaviorSubject,
//    of(), startWith()). Si no emite al instante, error NG0601 en runtime.
readonly config = toSignal(this.configSubject$, { requireSync: true });
// 4) manualCleanup: la suscripción NO se cancela al destruirse el contexto.
//    Solo para flujos verdaderamente globales cuyo ciclo de vida gestionas tú.
//    Úsalo con mucha cautela: es una fuga en potencia.
readonly reloj = toSignal(timer(0, 1000), { initialValue: 0, manualCleanup: true });
// 5) rejectErrors: por defecto, un error del observable se relanza al leer
//    la señal. Con rejectErrors: true se propaga como error no capturado y
//    la señal conserva el último valor bueno.
// ---------- toObservable ----------
// Internamente crea un effect, así que necesita contexto de inyección.
// Emite el valor actual al suscribirse y después cada cambio, agrupando
// (no garantiza una emisión por cada set()).
readonly filtro$ = toObservable(this.filtro);
Por qué toSignal necesita un valor inicial Una señal, por definición, siempre tiene un valor: la plantilla debe poder pintar algo en el primer render. Un observable puede tardar en emitir o no emitir nunca. Angular no puede inventarse un valor, así que te ofrece tres salidas: se lo das (initialValue), aceptas undefined en el tipo, o garantizas que la fuente emite de forma síncrona (requireSync). No hay una cuarta opción, y esa es justamente la pregunta de entrevista.

4.7.1 Guía práctica: qué usar para qué

NecesidadHerramientaPor qué
Estado local de un componentesignalSíncrono, sin ceremonia, legible en la plantilla
Valor derivado de otro estadocomputedMemoizado, perezoso y siempre coherente
Estado compartido entre componentesServicio con señalesSustituye al BehaviorSubject con menos código y sin suscripciones
Respuesta HTTP puntualrxResource o toSignalConvierte el flujo en estado leíble desde la plantilla, con carga y error incluidos
Entrada de usuario con dimensión temporalRxJS y luego toSignaldebounceTime y switchMap no tienen equivalente en señales
Websockets, eventos del servidor, sondeoRxJSFlujos infinitos con cancelación y reconexión
Coordinar varias peticionesRxJS (forkJoin, combineLatest)Semántica de espera y combinación explícita
Sincronizar con el DOM o una librería externaeffectEs el único punto de salida hacia lo no reactivo
Criterio de arquitectura

La dirección natural del flujo en una aplicación moderna es: RxJS en los bordes, señales en el centro. El evento entra por un observable (HTTP, ruta, socket, teclado), se procesa con operadores donde el tiempo importa y se convierte en señal en cuanto pasa a ser «estado que la interfaz pinta».

Evita el camino de vuelta salvo que lo necesites de verdad. Convertir una señal en observable solo para volver a convertirla en señal añade un salto asíncrono, complica la depuración y suele indicar que estabas buscando un computed.

4.8 Change detection a fondo

La detección de cambios es el proceso por el que Angular recorre el árbol de vistas, reevalúa las expresiones enlazadas de cada plantilla y, cuando el resultado difiere del anterior, escribe en el DOM. Todo lo demás —Zone.js, OnPush, las señales— son estrategias para decidir qué vistas recorrer y cuándo hacerlo.

4.8.1 Qué es una vista y cómo se organiza el árbol

La unidad de la detección de cambios no es el componente: es la vista. Internamente Angular representa cada instancia de plantilla con dos estructuras complementarias:

Hay dos tipos de vista hija. Las vistas de componente, creadas al instanciar un componente hijo, y las vistas embebidas, creadas por estructuras de la plantilla (@if, @for, ng-template). Ambas cuelgan de su vista padre y forman el árbol de vistas, que es el que se recorre en cada ciclo. Un ChangeDetectorRef inyectado en un componente no es más que una referencia a la vista anfitriona de ese componente.

Comprobar una vista consiste en: ejecutar sus instrucciones de binding, comparar cada valor nuevo con el guardado en el LView, actualizar el DOM en los que hayan cambiado, propagar los nuevos valores a las inputs de los hijos, ejecutar los hooks de ciclo de vida que correspondan y descender a las vistas hija.

4.8.2 Zone.js: cómo sabe Angular que ha pasado algo

Zone.js implementa un contexto de ejecución que sobrevive a las operaciones asíncronas. Lo consigue con monkey patching: al cargarse, sustituye las APIs asíncronas del navegador por versiones propias que notifican cuándo empieza y cuándo termina cada tarea.

CategoríaAPIs sustituidas por Zone.js
TemporizadoressetTimeout, setInterval, clearTimeout, requestAnimationFrame
EventosaddEventListener y removeEventListener de EventTarget
RedXMLHttpRequest (base de HttpClient), fetch, WebSocket
MicrotareasPromise, sustituida por una ZoneAwarePromise
ObservadoresMutationObserver, IntersectionObserver, geolocalización

Angular crea una zona propia (NgZone) y arranca la aplicación dentro de ella. Zone.js lleva la cuenta de las tareas pendientes; cuando la cola de microtareas de esa zona se vacía, emite el evento onMicrotaskEmpty, al que Angular responde ejecutando ApplicationRef.tick(). Ese tick es el ciclo de detección de cambios.

  CICLO COMPLETO CON ZONE.JS

    El usuario pulsa un botón
              │
              ▼
    addEventListener PARCHEADO  ──► Zone.js registra "empieza una tarea"
              │                      y la ejecuta DENTRO de NgZone
              ▼
    Se ejecuta tu manejador:  this.total = this.total + 1
              │                (Angular no se entera de esta asignación;
              ▼                 se entera de que la TAREA ha terminado)
    Zone.js: ¿quedan tareas o microtareas pendientes en NgZone?
              │
        sí ───┴─── no
        │           │
    esperar         ▼
              NgZone.onMicrotaskEmpty  ──►  ApplicationRef.tick()
                          │
                          ▼
              RECORRIDO DEL ÁRBOL DE VISTAS
              (una pasada, de la raíz hacia abajo)
                          │
                          ▼
              [solo en desarrollo] segunda pasada de verificación
              checkNoChanges  ──► si algo cambió: NG0100
                          │
                          ▼
              El navegador aplica estilo, layout y pintado
El coste real de Zone.js y sus puntos ciegos

El paquete pesa unos 35 KB antes de comprimir y, sobre todo, provoca un ciclo completo de detección de cambios ante cualquier actividad asíncrona, aunque no haya cambiado ningún dato: un mousemove, el setInterval de una librería de terceros o el final de una animación disparan el recorrido del árbol entero.

Además tiene puntos ciegos. Zone.js no puede interceptar las funciones async nativas del motor, porque no usan la promesa parcheada; por eso el compilador de Angular las transforma para que sí lo hagan. Tampoco intercepta las APIs que no ha parcheado, ni el código que ejecutas deliberadamente fuera de la zona.

mapa.component.ts · NgZone.runOutsideAngular
import { Component, ElementRef, NgZone, inject, signal } from '@angular/core';
@Component({ /* ... */ })
export class MapaComponent implements AfterViewInit {
  private readonly zone = inject(NgZone);
  private readonly host = inject(ElementRef<HTMLElement>);
  readonly coordenadas = signal({ x: 0, y: 0 });
  ngAfterViewInit(): void {
    // Sin runOutsideAngular, cada mousemove (cientos por segundo)
    // dispararía un tick completo. Con OnPush y Default por igual.
    this.zone.runOutsideAngular(() => {
      this.host.nativeElement.addEventListener('mousemove', (e) => {
        this.dibujarCursor(e);            // trabajo puro de canvas, sin Angular
        if (e.buttons === 1) {
          // Y cuando SÍ hay que actualizar la interfaz, se vuelve a entrar.
          // Con señales ni siquiera hace falta: escribir la señal ya notifica.
          this.zone.run(() => this.coordenadas.set({ x: e.clientX, y: e.clientY }));
        }
      });
    });
  }
}
Reducir ticks sin abandonar Zone.js provideZoneChangeDetection({ eventCoalescing: true }) agrupa varios eventos del DOM ocurridos en el mismo fotograma en un único ciclo de detección de cambios. Es un cambio de una línea, sin riesgo, y suele ser el primer paso de una migración hacia zoneless.

4.8.3 Default frente a OnPush

Con la estrategia Default (ChangeDetectionStrategy.Default), cada tick comprueba todas las vistas del árbol. Con OnPush, una vista solo se comprueba si está marcada como sucia. La lista de sucesos que la marcan es cerrada y hay que sabérsela de memoria:

Los cinco disparadores de una vista OnPush

  1. Una input recibe un valor distinto por referencia. Angular compara con Object.is el valor nuevo con el anterior. Si mutas el objeto y pasas la misma referencia, no cuenta. Lo mismo se aplica a ComponentRef.setInput().
  2. Se dispara un manejador de eventos declarado en la plantilla del componente —o un host listener del propio componente—. Marca la vista y todos sus ancestros hasta la raíz. Ojo: un evento en un componente hijo también marca a sus padres al subir por la cadena.
  3. Un AsyncPipe de la plantilla recibe un valor nuevo. El pipe llama internamente a markForCheck(). Esta es la razón de que el AsyncPipe funcione con OnPush mientras que un subscribe manual que asigna a un campo no lo haga.
  4. Cambia una señal que se lee en la plantilla (Angular v17 y posteriores). No marca la vista entera como sucia al estilo clásico: la marca «para refrescar» y marca a los ancestros como «tengo descendientes que refrescar», lo que permite alcanzarla aunque los padres estén limpios.
  5. Se llama explícitamente a ChangeDetectorRef.markForCheck().

Y, por simetría, lo que no marca una vista OnPush: mutar un objeto recibido como input, asignar un campo normal desde un setTimeout o desde el callback de un subscribe, y cualquier cambio provocado por código externo a Angular.

lista.component.tsINCORRECTO
@Component({
  selector: 'app-lista',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `@for (t of tareas; track t.id) { <p>{{ t.titulo }}</p> }`,
})
export class ListaComponent {
  @Input() tareas: Tarea[] = [];
  private campo = 0;
  constructor(private api: ApiService) {
    // Asignación a un campo normal desde un callback asíncrono:
    // con OnPush la vista NO se marca y esto no se ve nunca.
    this.api.tareas$.subscribe((t) => (this.tareas = t));
    setTimeout(() => (this.campo = 1), 1000);   // idem
  }
}
// Y en el padre, el error simétrico:
// this.tareas.push(nueva)  -> misma referencia, la input no cambia
lista.component.tsCORRECTO
@Component({
  selector: 'app-lista',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `@for (t of tareas(); track t.id) { <p>{{ t.titulo }}</p> }`,
})
export class ListaComponent {
  // Input basada en señal: leerla en la plantilla crea la dependencia
  // y su cambio marca la vista para refresco.
  readonly tareas = input.required<readonly Tarea[]>();
}
// En el padre, nueva referencia siempre:
// this.tareas.update((ts) => [...ts, nueva]);
// Alternativa sin señales, si el valor llega por observable:
// template: `@for (t of tareas$ | async; track t.id) { ... }`
// El AsyncPipe llama a markForCheck() por ti.

4.8.4 Cómo recorre Angular el árbol

El recorrido tiene dos fases y entenderlas explica por qué las señales son tan eficientes: la marca sube hasta la raíz y la comprobación baja siguiendo únicamente las marcas.

  ÁRBOL DE VISTAS.   [D] = Default   [P] = OnPush

                       App [D]
                          │
              ┌───────────┴───────────┐
          Cabecera [P]             Panel [P]
                                      │
                            ┌─────────┴─────────┐
                        Filtros [P]          Lista [P]
                                                │
                                       Fila [P] × 500

  ANTES · Zone.js + Default en todo el árbol
  ------------------------------------------------------------------
  Un mousemove, un temporizador o cualquier clic:
      se comprueban las 505 vistas, cambie lo que cambie.

  DESPUÉS · OnPush + señales.  Se ejecuta contador.set(9) y ese
  contador solo se lee en la plantilla de Fila #37
  ------------------------------------------------------------------
  FASE 1 · MARCADO (inmediato y baratísimo, sin tocar el DOM)

     App        ◄── HAS_CHILD_VIEWS_TO_REFRESH
      └ Panel   ◄── HAS_CHILD_VIEWS_TO_REFRESH
         └ Lista◄── HAS_CHILD_VIEWS_TO_REFRESH
            └ Fila#37  ◄── REFRESH_VIEW  (la vista que de verdad cambia)

     Se sube desde el consumidor hasta la raíz marcando ancestros
     (markAncestorsForTraversal) y se avisa al planificador.

  FASE 2 · COMPROBACIÓN (durante el tick, de la raíz hacia abajo)

     App        → no está sucia: NO se comprueban sus bindings,
                  pero se ATRAVIESA porque tiene marca de descendientes
      └ Cabecera→ sin marca: se PODA la rama entera
      └ Panel   → se atraviesa
         └ Filtros → se poda
         └ Lista   → se atraviesa
            └ Fila#1..#36  → se podan
            └ Fila#37      → SE COMPRUEBA y se actualiza el DOM
            └ Fila#38..#500→ se podan

     Coste real: 4 nodos visitados de 505.
La consecuencia práctica Con Default, el coste de un cambio es proporcional al tamaño de la aplicación. Con OnPush más señales, es proporcional a la profundidad del componente afectado. Por eso una tabla de 10.000 filas con OnPush puede ser más rápida que una lista de 50 con Default y funciones en la plantilla.

4.8.5 ChangeDetectorRef: control manual

MétodoQué hace exactamenteCuándo se usa
markForCheck()Marca esta vista y todos sus ancestros como sucios. No ejecuta la detección: solo garantiza que el próximo tick pase por aquíTras actualizar estado desde un subscribe en un componente OnPush
detectChanges()Ejecuta la detección de cambios ahora y de forma síncrona sobre esta vista y sus descendientes. No sube hacia la raízVistas desacopladas, integración con librerías externas, arreglar un NG0100 a conciencia
detach()Desengancha la vista del árbol de detección: tick() deja de visitarlaComponentes con datos de altísima frecuencia que se refrescan a mano
reattach()Vuelve a engancharlaAl reactivar la vista
checkNoChanges()Ejecuta la pasada de verificación que Angular hace en desarrollo; lanza NG0100 si algo ha cambiadoDepuración puntual
cotizaciones.component.ts · detach + detectChanges
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* ... */ })
export class CotizacionesComponent implements OnInit {
  private readonly cdr = inject(ChangeDetectorRef);
  precios: Precio[] = [];
  ngOnInit(): void {
    // Llegan 200 mensajes por segundo. Comprobar el árbol 200 veces
    // por segundo es inviable: desenganchamos y refrescamos a 20 fps.
    this.cdr.detach();
    this.socket.precios$
      .pipe(bufferTime(50), takeUntilDestroyed(this.destroyRef))
      .subscribe((lote) => {
        this.precios = fusionar(this.precios, lote);
        this.cdr.detectChanges();     // una sola comprobación por lote
      });
  }
}
// Con señales el mismo resultado se consigue sin tocar el
// ChangeDetectorRef: basta con escribir la señal cada 50 ms.

4.8.6 ExpressionChangedAfterItHasBeenCheckedError

Es, con diferencia, el error de Angular que más desconcierto genera. Su nombre lo dice todo, pero hace falta conocer el mecanismo para entenderlo: en modo desarrollo, después de cada ciclo de detección de cambios Angular ejecuta una segunda pasada de verificación (checkNoChanges) que vuelve a evaluar todas las expresiones enlazadas y comprueba que dan el mismo resultado. Si alguna ha cambiado, significa que la detección de cambios no ha convergido en una sola pasada, y Angular lo denuncia con el error NG0100.

Que solo ocurra en desarrollo no lo convierte en un aviso menor: en producción esa verificación no se ejecuta y el problema sigue ahí, manifestándose como valores desfasados un ciclo o como parpadeos.

provocarlo.ts · las tres formas clásicasINCORRECTO
// FORMA 1 · Modificar un valor enlazado en ngAfterViewInit.
// La vista YA se comprobó cuando se ejecuta este hook.
@Component({ template: `<p>{{ mensaje }}</p>` })
export class UnoComponent implements AfterViewInit {
  mensaje = 'inicial';
  ngAfterViewInit(): void {
    this.mensaje = 'cambiado';        // NG0100
  }
}
// FORMA 2 · Un hijo que modifica el estado del padre.
// El padre se comprueba ANTES que el hijo; cuando el hijo escribe,
// el binding del padre ya estaba verificado.
export class HijoComponent implements OnInit {
  ngOnInit(): void {
    this.padre.cargando = false;      // NG0100 en el binding del padre
  }
}
// FORMA 3 · Una expresión de plantilla que devuelve algo distinto
// en cada llamada. Es el caso más traicionero porque parece inocente.
@Component({
  template: `
    <app-hijo [config]="{ modo: 'lectura' }" />   <!-- objeto nuevo cada vez -->
    <p>{{ ahora() }}</p>
    <p>{{ items.filter(esVisible).length }}</p>  <!-- array nuevo cada vez -->
  `,
})
export class TresComponent {
  ahora(): number { return Date.now(); }          // NG0100
}
arreglarlo.ts · las soluciones correctasCORRECTO
// SOLUCIÓN 1 · Mover la escritura a un hook ANTERIOR a la comprobación.
// ngOnInit se ejecuta antes de que la vista se compruebe: no hay conflicto.
ngOnInit(): void { this.mensaje = 'cambiado'; }
// SOLUCIÓN 2 · Usar señales. Escribir una señal notifica al planificador,
// que agenda un nuevo ciclo de detección de cambios de forma ordenada.
// Este es el motivo por el que el NG0100 casi desaparece al migrar.
readonly mensaje = signal('inicial');
ngAfterViewInit(): void { this.mensaje.set('cambiado'); }
// SOLUCIÓN 3 · Invertir la dependencia: que el hijo NOTIFIQUE en lugar de
// escribir en el padre. El padre reacciona en su propio ciclo.
readonly listo = output<void>();
ngOnInit(): void { this.listo.emit(); }
// SOLUCIÓN 4 · Estabilizar la expresión de plantilla: valores constantes,
// campos precalculados o computed. Nunca funciones que creen objetos.
protected readonly CONFIG = { modo: 'lectura' } as const;
readonly visibles = computed(() => this.items().filter(esVisible).length);
// SOLUCIÓN 5 · Cuando el cambio en ngAfterViewInit es INEVITABLE (por
// ejemplo, medir el DOM real para calcular un tamaño), fuerza una
// comprobación consciente y documentada:
constructor(private cdr: ChangeDetectorRef) {}
ngAfterViewInit(): void {
  this.altura = this.host.nativeElement.offsetHeight;
  this.cdr.detectChanges();          // decisión explícita, no un parche
}
Por qué setTimeout(() => ...) «arregla» el error y por qué no debes usarlo así Aplazar la escritura a una macrotarea la saca del ciclo actual, de modo que ocurre en un ciclo de detección de cambios nuevo y la verificación no la ve. El error desaparece, pero el diseño sigue mal: has introducido un ciclo de detección de cambios extra, un fotograma en el que el usuario ve el valor viejo (parpadeo) y una condición de carrera silenciosa. Úsalo solo cuando entiendas por qué lo haces y déjalo documentado con un comentario; nunca como reflejo automático ante un NG0100.

4.8.7 Modo zoneless

En modo zoneless Angular prescinde de Zone.js. Ya no hay nadie vigilando las APIs del navegador; en su lugar, el framework programa un ciclo de detección de cambios cuando alguien se lo notifica explícitamente. Las fuentes de notificación son, esencialmente, las mismas que marcan una vista OnPush: el cambio de una señal leída en una plantilla, markForCheck(), el AsyncPipe, setInput(), los manejadores de eventos de plantilla y el enganche de una vista nueva.

Con Zone.jsZoneless
Qué dispara el cicloCualquier tarea asíncronaSolo notificaciones explícitas del framework
Tamaño del bundle+35 KB aproximadamenteSin ese coste
Ciclos innecesariosMuchos (mousemove, temporizadores ajenos)Prácticamente ninguno
DepuraciónPilas de llamadas con marcos de Zone.jsPilas limpias
RequisitoNingunoEl estado debe notificar: señales, AsyncPipe o markForCheck
app.config.ts · activación
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    // ...
  ],
};
// NOMBRE SEGÚN LA VERSIÓN:
//   v18 y v19  -> provideExperimentalZonelessChangeDetection()
//   v20 y post -> provideZonelessChangeDetection()  (estable)
// Comprueba cuál exporta tu versión antes de copiar esto.
// Además hay que eliminar zone.js del proyecto:
//   angular.json  -> quitar "zone.js" de "polyfills" en build y test
//   package.json  -> se puede desinstalar cuando ya no lo use nada
//   src/polyfills.ts (proyectos antiguos) -> borrar import 'zone.js';
Patrones que rompe el modo zoneless
  • Campos normales mutados desde setTimeout, setInterval o Promise.then. Antes funcionaban «por accidente» gracias al tick que provocaba Zone.js. Ahora no se refresca nada.
  • Callbacks de librerías externas (mapas, gráficas, editores, SDK de pagos) que escriben en campos del componente.
  • subscribe manual que asigna a un campo en lugar de a una señal.
  • Dependencias de NgZone: onStable, onMicrotaskEmpty, isStable y los runOutsideAngular que ya no tienen sentido.
  • Pruebas que se apoyan en la estabilización automática de la zona.

Migración paso a paso, sin sobresaltos

  1. Activa provideZoneChangeDetection({ eventCoalescing: true }). Coste cero, beneficio inmediato y ninguna ruptura.
  2. Pon OnPush en todos los componentes. Este es el paso que de verdad cuesta: si tu aplicación funciona con OnPush en todas partes, está a un paso de funcionar sin zona, porque los requisitos son casi los mismos.
  3. Sustituye el estado mutable por señales. Empieza por los componentes que rompan al activar OnPush: son exactamente los que dependían del tick global.
  4. Cambia subscribe por AsyncPipe o toSignal.
  5. Envuelve los callbacks de terceros. Que escriban en una señal, no en un campo.
  6. Activa el proveedor zoneless y recorre la aplicación entera probando cada pantalla.
  7. Elimina zone.js de angular.json y mide el resultado.

Cómo detectar los problemas. El síntoma es siempre el mismo: algo no se repinta. Para localizarlo, comprueba en este orden si el dato que no se ve procede de una señal leída en la plantilla, de un AsyncPipe o de un markForCheck(); si no es ninguno de los tres, ahí está el fallo. El profiler de Angular DevTools ayuda: si al provocar el cambio no aparece ningún ciclo de detección de cambios, el problema es de notificación; si aparece pero la vista no se actualiza, el problema es de referencia (has mutado en lugar de reemplazar).

4.8.8 Rendimiento: medir antes de optimizar

Angular DevTools (extensión oficial para navegadores basados en Chromium) tiene una pestaña Profiler que graba los ciclos de detección de cambios. Cada barra es un ciclo; al seleccionarla se ve el árbol de componentes comprobados, el tiempo de cada uno y, muy importante, qué lo disparó.

IndicadorValor saludableSi te pasas
Duración de un cicloMenos de 16 ms (60 fps)La interfaz se nota pastosa al escribir o al desplazar
Ciclos por interacción1 o 2Hay efectos que escriben señales o setTimeout encadenados
Componentes comprobados por cicloLos que dependen del cambioFalta OnPush, o hay inputs que cambian de referencia sin necesidad
Ciclos «vacíos» (sin cambios en el DOM)Casi ningunoZone.js reaccionando a eventos irrelevantes: runOutsideAngular o zoneless

Optimizaciones que casi siempre pagan

  • OnPush en todos los componentes desde el primer día del proyecto.
  • track correcto en @for: sin él, Angular destruye y recrea el DOM de la lista entera en cada cambio.
  • Nada de llamadas a funciones en la plantilla: se evalúan en cada comprobación. Sustitúyelas por computed o por un pipe puro.
  • @defer para lo que está por debajo del pliegue o detrás de una interacción.
  • Desplazamiento virtual en listas largas: el DOM que no existe no cuesta nada.
  • runOutsideAngular para animaciones, canvas, arrastre y sensores.

Casos reales de optimización

  • Tabla de 2.000 filas que tarda 900 ms al filtrar. Causa: Default más un método filtrar() invocado desde la plantilla, reevaluado por fila y por ciclo. Solución: computed con el resultado y OnPush. Resultado típico: menos de 30 ms.
  • Formulario que se ralentiza al teclear. Causa: un valueChanges sin debounceTime que dispara validación asíncrona en cada tecla. Solución: debounceTime(300) más distinctUntilChanged.
  • Aplicación con mapa que consume CPU en reposo. Causa: la librería registra mousemove dentro de la zona. Solución: runOutsideAngular.
  • Panel que parpadea al recibir datos por websocket. Causa: cada mensaje reemplaza el array entero. Solución: bufferTime más fusión incremental y track estable.

4.9 Gestión de estado con señales

El patrón dominante en Angular moderno es el servicio-store: un servicio inyectable con señales privadas para el estado, señales de solo lectura y computed como API pública, y métodos que son las únicas escrituras posibles. Ni BehaviorSubject, ni reducers, ni acciones: un objeto con métodos y estado encapsulado, que es también el diseño más fácil de probar.

4.9.1 Un store de producción, completo

tareas.store.ts · carga, error, filtros y actualizaciones optimistas
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

export type EstadoCarga = 'inactivo' | 'cargando' | 'listo' | 'error';
export interface Filtros { texto: string; estado: 'todas' | 'pendientes' | 'hechas'; }
@Injectable({ providedIn: 'root' })
export class TareasStore {
  private readonly http = inject(HttpClient);

  // ---------- ESTADO PRIVADO: la única fuente de verdad ----------
  // Normalizado: un mapa por id, más el orden aparte. Ver 4.9.3.
  private readonly _porId = signal<ReadonlyMap<number, Tarea>>(new Map());
  private readonly _orden = signal<readonly number[]>([]);
  private readonly _carga = signal<EstadoCarga>('inactivo');
  private readonly _error = signal<string | null>(null);
  private readonly _filtros = signal<Filtros>({ texto: '', estado: 'todas' });

  // ---------- API PÚBLICA: solo lectura y derivados ----------
  readonly carga = this._carga.asReadonly();
  readonly error = this._error.asReadonly();
  readonly filtros = this._filtros.asReadonly();
  readonly tareas = computed<readonly Tarea[]>(() => {
    const mapa = this._porId();
    return this._orden().map((id) => mapa.get(id)!).filter(Boolean);
  });
  readonly visibles = computed(() => {
    const { texto, estado } = this._filtros();
    const q = texto.trim().toLowerCase();
    return this.tareas().filter((t) =>
      (q === '' || t.titulo.toLowerCase().includes(q)) &&
      (estado === 'todas' || (estado === 'hechas') === t.hecha),
    );
  });
  readonly pendientes = computed(() => this.tareas().filter((t) => !t.hecha).length);
  readonly vacio = computed(() => this._carga() === 'listo' && this.tareas().length === 0);
  readonly sinResultados = computed(() => this.tareas().length > 0 && this.visibles().length === 0);

  // ---------- COMANDOS: las únicas escrituras ----------
  filtrar(parcial: Partial<Filtros>): void {
    this._filtros.update((f) => ({ ...f, ...parcial }));
  }
  async cargar(): Promise<void> {
    this._carga.set('cargando');
    this._error.set(null);
    try {
      const tareas = await firstValueFrom(this.http.get<Tarea[]>('/api/tareas'));
      this._porId.set(new Map(tareas.map((t) => [t.id, t])));
      this._orden.set(tareas.map((t) => t.id));
      this._carga.set('listo');
    } catch (e) {
      this._error.set(mensajeDeError(e));
      this._carga.set('error');
    }
  }

  // ACTUALIZACIÓN OPTIMISTA: pintamos el cambio de inmediato y
  // revertimos si el servidor lo rechaza. La interfaz se siente instantánea.
  async alternarHecha(id: number): Promise<void> {
    const anterior = this._porId().get(id);
    if (!anterior) return;
    this.aplicar(id, { ...anterior, hecha: !anterior.hecha });   // 1) optimista
    try {
      const guardada = await firstValueFrom(
        this.http.patch<Tarea>(`/api/tareas/${id}`, { hecha: !anterior.hecha }),
      );
      this.aplicar(id, guardada);                                 // 2) confirmación
    } catch (e) {
      this.aplicar(id, anterior);                                 // 3) reversión
      this._error.set('No se pudo guardar el cambio. Se ha deshecho.');
    }
  }
  // Escritura inmutable puntual: nuevo Map, nunca mutación del anterior.
  private aplicar(id: number, tarea: Tarea): void {
    this._porId.update((m) => new Map(m).set(id, tarea));
  }
}
Qué hace bueno a este store Estado privado y mínimo; todo lo demás derivado con computed; escrituras únicamente a través de métodos con nombre de intención; inmutabilidad en cada actualización; los estados de carga y error forman parte del modelo, no son flags sueltos; y ni una sola suscripción que cancelar. Probarlo es llamar a un método y leer una señal, sin TestBed ni marbles.

4.9.2 Estado local frente a estado compartido

Tipo de estadoDónde viveEjemplos
De componenteSeñales en el propio componentePestaña activa, acordeón abierto, texto del formulario antes de enviar
De funcionalidadServicio provisto en la ruta o en el componente contenedorFiltros y paginación de un listado, borrador de un asistente por pasos
De aplicaciónServicio providedIn: 'root'Sesión, permisos, tema, carrito, notificaciones
De servidorresource/rxResource o una capa de cachéDatos que en realidad pertenecen al backend
De URLEl router, no una señalIdentificador del detalle, página, orden, filtros compartibles por enlace

Dos errores simétricos: subir a un servicio raíz estado que solo le importa a un componente (crea acoplamiento y fugas entre pantallas) y duplicar en una señal algo que ya está en la URL (se desincronizan en cuanto el usuario pulsa «atrás»). La regla es sencilla: el estado vive en el ámbito más pequeño que lo necesita, y lo que debe sobrevivir a una recarga o ser compartible por enlace vive en la URL.

4.9.3 Inmutabilidad y normalización

Ya hemos visto que la inmutabilidad no es una preferencia estilística en Angular: es el mecanismo mismo de la detección de cambios. Dos técnicas complementarias reducen el coste de mantenerla:

4.9.4 ¿Cuándo hace falta una librería?

Servicios con señalesNgRx SignalStoreNgRx Store clásico
ModeloObjeto con estado y métodosStore declarativo con withState, withComputed, withMethodsRedux: acciones, reducers, selectors, effects
Código baseMínimoBajoAlto: cada operación toca cuatro ficheros
Curva de aprendizajeNula si sabes señalesBajaNotable
HerramientasAngular DevToolsRedux DevTools con extensiónRedux DevTools, viaje en el tiempo, trazabilidad total
Composición y reutilizaciónManual (herencia o composición de servicios)Muy buena: features personalizadas reutilizablesBuena mediante feature states
Encaja bien cuando…El estado es de una funcionalidad y las mutaciones son localesHay varios stores con patrones repetidos que quieres factorizarEquipo grande, auditoría de acciones, flujos complejos con muchos efectos
Coste ocultoConvenciones que hay que disciplinar en revisión de códigoDependencia externa con su propio ritmo de versionesCeremonia constante; se abandona a medias con frecuencia
Recomendación honesta Empieza siempre con servicios con señales. La inmensa mayoría de las aplicaciones de gestión no necesitan nada más, y el coste de migrar después a NgRx SignalStore es bajo porque el modelo mental es el mismo. Plantéate una librería cuando aparezcan síntomas concretos: necesitas auditar quién cambió qué, tienes flujos de efectos encadenados difíciles de seguir, o repites la misma estructura de store en diez sitios. Adoptar Redux «por si acaso» en un CRUD es la forma más rápida de multiplicar el código sin ganar nada.

4.10 Errores comunes y cómo solucionarlos

Error o síntomaCausa realSolución
La vista no se actualiza y no hay ningún errorMutación sin cambiar la referencia (push, sort, asignación a una propiedad)Actualización inmutable: update((x) => [...x, nuevo]); tipar como readonly
La vista no se actualiza en un componente OnPushSe asigna a un campo normal desde un subscribe o un setTimeoutSeñal, AsyncPipe o markForCheck()
NG0100 ExpressionChangedAfterItHasBeenChecked­ErrorUn valor enlazado cambia después de la comprobación de la vistaMover a ngOnInit, usar señales, invertir la dependencia o detectChanges() consciente (4.8.6)
NG0203: fuera de contexto de inyeccióneffect, toSignal, inject o toObservable llamados fuera del constructorMoverlos al constructor o a un campo, o pasar { injector }
NG0600: escritura de señal no permitidaEscribir una señal dentro de un computed (o de un effect en v16–v18)Convertir la derivación en computed; los efectos, solo para lo externo
NG0601: requireSync sin emisión síncronaEl observable pasado a toSignal no emite al suscribirseUsar initialValue, o añadir startWith a la fuente
La petición HTTP nunca se lanzaEl observable es perezoso y nadie se ha suscritosubscribe, AsyncPipe, toSignal o firstValueFrom
Se lanzan varias peticiones idénticasVarios suscriptores a un observable fríoshareReplay({ bufferSize: 1, refCount: true })
Resultados de búsqueda desordenados o parpadeantesmergeMap donde correspondía switchMapswitchMap más debounceTime y distinctUntilChanged
Se crean registros duplicados al pulsar dos vecesmergeMap o switchMap en una escrituraexhaustMap
El flujo deja de responder tras un errorUn error termina el observable de forma definitivacatchError dentro del operador de aplanado
La memoria crece al navegar entre páginasSuscripciones no canceladas; shareReplay sin refCounttakeUntilDestroyed() como último operador; revisar los share
La pestaña se congelaCiclo de efectos que se escriben mutuamenteSustituir la derivación por computed; untracked donde proceda
Todo se recomprueba con cada movimiento del ratónZone.js reaccionando a eventos de alta frecuenciarunOutsideAngular, eventCoalescing o modo zoneless
Nada se refresca tras activar zonelessEl estado no notifica: campos normales en vez de señalesMigrar a señales, AsyncPipe o markForCheck()
Un computed devuelve siempre el mismo valorSe leyó la señal fuera del cálculo y se capturó en una constanteLeer la señal dentro de la función del computed
La lista se recrea entera en cada cambio@for sin un track establetrack t.id; nunca track $index si el orden cambia

4.11 Buenas y malas prácticas

Haz esto

  • Señales para el estado, computed para lo derivado y effect solo para salir hacia el exterior.
  • Una única fuente de verdad por dato: si se puede derivar, se deriva.
  • Estado privado y API de solo lectura en los servicios (asReadonly()).
  • Actualizaciones inmutables y tipos readonly para que el compilador te proteja.
  • OnPush en todos los componentes desde el primer commit del proyecto.
  • AsyncPipe o toSignal antes que subscribe manual.
  • takeUntilDestroyed() como último operador cuando el subscribe es inevitable.
  • switchMap para leer, exhaustMap para escribir desde un botón y concatMap cuando el orden es sagrado.
  • catchError dentro del aplanado, para que el flujo externo sobreviva.
  • Mide con Angular DevTools antes y después de cada optimización.

Evita esto

  • Derivar estado con effect. Es el antipatrón número uno: si escribes una señal a partir de otras, querías un computed.
  • Mutar arrays y objetos dentro de una señal.
  • Llamar a funciones desde la plantilla: se evalúan en cada comprobación.
  • Anidar subscribe dentro de subscribe: usa un operador de aplanado.
  • shareReplay(1) sin refCount sobre flujos infinitos.
  • Poner setTimeout para callar un NG0100 sin entender la causa.
  • Exponer Subject o señales escribibles desde un servicio.
  • Convertir señal → observable → señal sin una razón temporal concreta.
  • Suscribirse solo para asignar a un campo: eso es un toSignal.
  • Adoptar una librería de estado «por si acaso» antes de tener el problema que resuelve.

4.12 Preguntas frecuentes

¿Cuál es la diferencia exacta entre signal, computed y effect?
signal es estado escribible: el origen. computed es estado derivado de solo lectura, memoizado y perezoso: no se calcula si nadie lo lee y devuelve el valor en caché mientras sus dependencias no cambien. effect no produce ningún valor: es una reacción que se ejecuta cuando cambia algo que ha leído, y su cometido es sincronizar con el mundo exterior (DOM, almacenamiento, red, registro). La prueba práctica: si alguien va a leer el resultado, es un computed; si el resultado sale de Angular, es un effect.
Cambio el estado y la vista no se actualiza. ¿Por dónde empiezo?
Por tres comprobaciones en este orden. Una: ¿has cambiado la referencia? Un push o una asignación a una propiedad interna no notifican nada. Dos: ¿el valor se lee realmente en la plantilla como señal —con paréntesis— o a través del AsyncPipe? Tres: si el componente es OnPush, ¿el cambio procede de alguno de los cinco disparadores de 4.8.3? Un effect(() => console.log(this.estado())) temporal resuelve la duda al instante: si no se dispara, el problema es de notificación; si se dispara y la vista no cambia, el problema es de detección de cambios.
switchMap o mergeMap: ¿cómo lo decido?
Pregúntate qué debe pasar si llega un valor nuevo mientras el anterior sigue en curso. Si el resultado antiguo ya no interesa —un buscador, un cambio de ruta—, switchMap, que además cancela la petición. Si todos los resultados importan y el orden es indiferente, mergeMap. Si todos importan y el orden es obligatorio, concatMap. Si hay que ignorar lo nuevo hasta terminar lo actual —un botón de envío—, exhaustMap. Y una advertencia: nunca uses switchMap con peticiones de escritura, porque cancelar la petición no deshace lo que el servidor ya haya hecho.
¿Qué es OnPush y por qué debería usarlo siempre?
Es la estrategia de detección de cambios que le dice a Angular «no compruebes esta vista a menos que esté marcada como sucia». Reduce el trabajo de cada ciclo de un recorrido completo del árbol a solo las ramas afectadas. Merece la pena siempre porque, además del rendimiento, impone una disciplina saludable: obliga a trabajar con datos inmutables y con flujos explícitos, que es exactamente lo que hace falta para poder pasar a modo zoneless más adelante. Adoptarlo desde el principio es barato; retrofitarlo en una aplicación grande es un proyecto en sí mismo.
¿Qué hace exactamente el modo zoneless?
Elimina Zone.js, y con él la detección automática de actividad asíncrona. Angular deja de ejecutar un ciclo de detección de cambios «por si acaso» y lo programa solo cuando recibe una notificación explícita: una señal leída en plantilla que cambia, un markForCheck(), el AsyncPipe, un setInput() o un manejador de eventos de plantilla. Se gana tamaño de bundle, se eliminan los ciclos inútiles y las pilas de llamadas quedan limpias. A cambio, cualquier estado que no notifique deja de refrescar la vista, que es justo lo que rompe al migrar.
¿Por qué toSignal necesita un valor inicial?
Porque una señal siempre tiene un valor y un observable no. La plantilla tiene que pintar algo en el primer render, antes de la primera emisión. Angular no puede inventarse ese valor, así que ofrece tres alternativas: initialValue (el tipo resultante es Signal<T>), no pasar nada (el tipo es Signal<T | undefined> y tú decides qué pintar mientras tanto) o requireSync: true, que es una promesa tuya de que la fuente emite síncronamente al suscribirse —como un BehaviorSubject— y que produce un error en tiempo de ejecución si mientes.
¿Han venido las señales a sustituir a RxJS en Angular?
No, y el propio equipo de Angular lo ha dicho explícitamente. Las señales cubren el estado síncrono y su derivación, que era donde RxJS resultaba desproporcionado. RxJS sigue siendo insustituible para todo lo que tiene dimensión temporal: debounce, cancelación, reintentos con retroceso, sondeo, websockets, coordinación de varias peticiones. La arquitectura sana es RxJS en los bordes y señales en el centro.
¿Cuántas veces se ejecuta la función de un computed?
Cero veces si nadie lo lee, por muchas veces que cambien sus dependencias. Una vez tras cada cambio real de una dependencia, y solo en el momento en que alguien lee el valor. Leerlo cien veces seguidas sin que cambie nada ejecuta la función cero veces adicionales: devuelve el valor memoizado. Y si una dependencia se reescribe con un valor igual según su función equal, la propagación se corta antes de llegar y tampoco se recalcula.
¿Puedo escribir una señal dentro de un effect?
Técnicamente depende de la versión: estaba prohibido por defecto hasta la v18 (había que pasar allowSignalWrites: true) y está permitido a partir de la v19. Pero la pregunta importante es otra: ¿deberías? Casi nunca. Si el valor que escribes se deriva de otras señales, corresponde a un computed; si es un estado escribible que se reinicia con su fuente, a un linkedSignal. Escribir señales desde efectos es la vía más rápida hacia bucles de detección de cambios y hacia un estado imposible de razonar.
¿Por qué mi @for recrea todo el DOM en cada actualización?
Porque la expresión de track no identifica de forma estable a cada elemento. Si usas track $index y la lista se reordena o se inserta al principio, todos los índices cambian y Angular concluye que todos los elementos son nuevos: destruye el DOM, pierde el foco y el estado de los componentes hijos. Usa siempre un identificador estable de dominio (track t.id). Y recuerda que sin track el rendimiento de una lista larga se degrada muy deprisa.
¿Qué diferencia hay entre markForCheck() y detectChanges()?
markForCheck() no ejecuta nada: se limita a marcar la vista y todos sus ancestros como sucios, de modo que el próximo ciclo pase por ellos. Es lo que hace el AsyncPipe internamente y lo que necesitas el 95 % de las veces con OnPush. detectChanges() ejecuta la comprobación ahora mismo, de forma síncrona, sobre esa vista y sus descendientes, sin subir hacia la raíz. Se reserva para vistas desacopladas con detach() y para integraciones con librerías externas. Abusar de detectChanges() suele indicar que falta una señal en alguna parte.
¿Debo poner async/await o RxJS en mis servicios HTTP?
Depende de si necesitas cancelación o composición temporal. Para una operación puntual —un POST de guardado o una carga inicial que no se cancela—, firstValueFrom con async/await produce código más legible y encaja de forma natural con las señales. Para lecturas que dependen de entradas del usuario o de la ruta, el observable es superior porque permite switchMap, debounceTime, retry y la cancelación real de la petición. Un consejo: no mezcles ambos estilos dentro de un mismo servicio sin un criterio escrito.
¿Cómo pruebo un componente con señales y OnPush?
Las señales se prueban sin ningún andamiaje: se llama al método del store y se lee la señal, porque todo es síncrono. Para la plantilla, sigue haciendo falta fixture.detectChanges() —o fixture.autoDetectChanges()— para que el DOM refleje el estado, y con inputs basadas en señales se establecen con fixture.componentRef.setInput('nombre', valor), que además marca la vista correctamente. La lógica de RxJS se prueba con TestScheduler y diagramas de canicas, o con fakeAsync y tick() si prefieres tiempo simulado.
¿Qué pasa si un observable emite un error dentro de toSignal?
Por defecto, el error se guarda y se vuelve a lanzar cuando se lee la señal, lo que suele manifestarse como una excepción durante el renderizado de la plantilla. Por eso conviene tratar los errores antes de convertir: un catchError que devuelva un valor de reserva, o un modelo de estado explícito del tipo { estado: 'error', mensaje }. En la práctica, si el flujo puede fallar, rxResource suele ser mejor opción que toSignal, porque expone el error como una señal más en lugar de lanzarlo.

4.13 Ejercicios

Nivel 1 · básico

4.1 Crea un componente contador con signal que muestre el valor, su doble y si es par, usando computed para las dos derivaciones. Añade un effect que registre cada cambio en consola y comprueba cuántas veces se ejecuta cada computed añadiendo un console.log dentro. Explica el resultado.

4.2 Dado tareas = signal<Tarea[]>([...]), escribe los métodos anadir, eliminar, alternarHecha y ordenarPorFecha de forma inmutable. Después tipa el estado como readonly Tarea[] y comprueba qué errores de compilación aparecen.

4.3 Convierte this.route.queryParamMap en una señal con toSignal. Hazlo primero sin initialValue y observa el tipo resultante; después con initialValue. Explica por qué cambia.

4.4 Escribe un componente que muestre la hora actual actualizada cada segundo, usando timer(0, 1000) y toSignal. Verifica con Angular DevTools que solo se comprueba ese componente y no toda la aplicación (ponlo en OnPush).

Nivel 2 · intermedio

4.5 Implementa un buscador de usuarios: una señal con el texto, conversión a observable, debounceTime(300), distinctUntilChanged(), switchMap a la API con catchError interno, y vuelta a señal. Añade señales derivadas cargando, sinResultados y error. Comprueba en la pestaña de red del navegador que las peticiones anteriores se cancelan.

4.6 Escribe un linkedSignal para la fila seleccionada de una tabla que conserve la selección si la fila sigue existiendo tras recargar los datos y que seleccione la primera en caso contrario. Compara tu solución con la equivalente basada en signal más effect y enumera las diferencias observables.

4.7 Provoca deliberadamente los tres casos de ExpressionChangedAfterItHasBeenCheckedError descritos en 4.8.6 y arregla cada uno con la técnica adecuada. Justifica por escrito por qué el setTimeout no es la solución correcta en ninguno de los tres.

4.8 Implementa un botón de guardar a prueba de dobles clics con exhaustMap, con señal de enviando, gestión de errores y navegación tras el éxito. Después sustituye exhaustMap por mergeMap y documenta qué ocurre en el servidor.

4.9 Escribe un servicio de sondeo que consulte un estado cada 10 segundos, se detenga cuando la pestaña no está visible, reintente con retroceso exponencial ante errores de servidor y no reintente ante errores 4xx.

Nivel 3 · avanzado

4.10 Construye el store completo de la sección 4.9 con actualizaciones optimistas y escribe pruebas unitarias que verifiquen los tres caminos: éxito, error con reversión y llamadas concurrentes sobre la misma entidad.

4.11 Migra una aplicación pequeña a modo zoneless siguiendo los siete pasos de 4.8.7. Documenta cada componente que se rompe, la causa exacta y la solución aplicada. Mide el tamaño del bundle y el número de ciclos de detección de cambios antes y después.

4.12 Implementa una Signal propia en TypeScript puro, sin Angular: un grafo productor/consumidor con memoización, marcado sucio, poda por igualdad y evaluación perezosa. Demuestra con un test que tu implementación es glitch-free: al actualizar dos fuentes de un mismo computed, este no debe observar nunca un estado intermedio.

4.13 Toma una tabla de 5.000 filas con Default y funciones en la plantilla, perfílala con Angular DevTools y optimízala hasta bajar de 16 ms por interacción. Registra la mejora tras cada cambio: track, OnPush, computed, desplazamiento virtual.

Soluciones comentadas (4.2, 4.5 y 4.6)

4.2 · Actualizaciones inmutables. Lo importante es que toda escritura produzca una referencia nueva del array y, si se modifica un elemento, también del objeto.

private readonly _tareas = signal<readonly Tarea[]>([]);
anadir(t: Tarea): void {
  this._tareas.update((ts) => [...ts, t]);
}
eliminar(id: number): void {
  this._tareas.update((ts) => ts.filter((t) => t.id !== id));
}
alternarHecha(id: number): void {
  // map crea un array nuevo Y sustituye solo el objeto afectado:
  // los demás conservan su referencia, lo que ayuda a OnPush en los hijos.
  this._tareas.update((ts) =>
    ts.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t)),
  );
}
ordenarPorFecha(): void {
  // toSorted (ES2023) no muta. Con targets antiguos: [...ts].sort(...)
  this._tareas.update((ts) =>
    ts.toSorted((a, b) => a.creada.getTime() - b.creada.getTime()),
  );
}
// Al tipar como 'readonly Tarea[]', el compilador rechaza push, sort,
// splice y la asignación por índice. Ese error de compilación es
// justo la red de seguridad que buscas: convierte un fallo silencioso
// en tiempo de ejecución en un fallo ruidoso en tiempo de compilación.

4.5 · Buscador completo. Fíjate en tres detalles: el catchError va dentro del switchMap, el estado de carga se gestiona con tap y finalize, y la conversión final a señal evita cualquier suscripción manual.

@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* ... */ })
export class BuscadorComponent {
  private readonly api = inject(ApiService);
  readonly texto = signal('');
  readonly cargando = signal(false);
  readonly error = signal<string | null>(null);
  readonly resultados = toSignal(
    toObservable(this.texto).pipe(
      map((t) => t.trim()),
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((q) => {
        if (q.length < 2) return of([] as Usuario[]);
        this.cargando.set(true);
        this.error.set(null);
        return this.api.buscarUsuarios(q).pipe(
          // DENTRO del switchMap: un fallo no mata el flujo externo
          catchError((e) => { this.error.set(mensaje(e)); return of([]); }),
          finalize(() => this.cargando.set(false)),
        );
      }),
    ),
    { initialValue: [] as Usuario[] },
  );
  readonly sinResultados = computed(() =>
    !this.cargando() && this.texto().trim().length >= 2 &&
    this.resultados().length === 0,
  );
}

Nota de diseño: escribir señales desde dentro de un pipe de RxJS funciona, pero mezcla dos modelos. Una alternativa más limpia y declarativa es rxResource, que expone isLoading() y error() como señales sin que tengas que sincronizarlas a mano.

4.6 · Selección estable con linkedSignal.

readonly filas = signal<readonly Fila[]>([]);
readonly seleccionada = linkedSignal<readonly Fila[], Fila | null>({
  source: this.filas,
  computation: (filas, previo) => {
    const anterior = previo?.value;
    // Conserva la selección si la fila sigue presente tras recargar.
    const encontrada = anterior
      ? filas.find((f) => f.id === anterior.id)
      : undefined;
    return encontrada ?? filas[0] ?? null;
  },
});
// Diferencias frente a signal + effect:
//  1. No hay ciclo de retraso: el valor es correcto en la MISMA
//     comprobación en que cambian las filas, no en la siguiente.
//  2. No hay riesgo de bucle: no se escriben señales desde un efecto.
//  3. La relación "depende de filas" está declarada en un solo sitio.
//  4. Es perezoso: si nadie lee la selección, no se calcula.

4.14 Resumen del capítulo

  • La reactividad es un problema de notificación, no de renderizado. Existen tres familias de soluciones y Angular ha recorrido las tres: comprobación sucia, flujos push y grafo de dependencias.
  • Una señal es una celda de hoja de cálculo: signal es el valor, computed la fórmula y effect la macro que actúa sobre el exterior. La asignación de responsabilidades entre las tres es la decisión de diseño más importante del capítulo.
  • El grafo empuja la invalidación y tira del valor. De ahí salen la memoización, la pereza, la ausencia de glitches y un coste proporcional a lo que cambia y no al tamaño de la aplicación.
  • La inmutabilidad no es opcional: las señales y OnPush comparan referencias. Mutar un array es la causa número uno de «la vista no se actualiza».
  • RxJS sigue siendo necesario para todo lo que tiene dimensión temporal. Los cuatro operadores de aplanado se eligen respondiendo a una sola pregunta: qué hacer con el trabajo en curso cuando llega algo nuevo.
  • Toda suscripción manual debe morir con su componente: AsyncPipe, toSignal o takeUntilDestroyed() como último operador.
  • La detección de cambios recorre vistas, no componentes. Con OnPush, solo se comprueban las marcadas; con señales, la marca sube hasta la raíz y la comprobación baja podando todo lo que no está marcado.
  • NG0100 avisa de que la detección de cambios no converge en una pasada. Se arregla en el diseño, no con un setTimeout.
  • El modo zoneless es la dirección del framework. El camino pasa por OnPush en todas partes y estado basado en señales; quien ya está ahí, casi ha migrado.
  • Empieza con servicios con señales para el estado. Adopta una librería cuando tengas el problema concreto que resuelve, no antes.

4.15 Recursos adicionales

Siguiente paso Ya sabes cómo fluye el estado y cuándo se repinta la interfaz. El capítulo 5 aplica todo esto a las plantillas: el nuevo control flow (@if, @for, @switch), @defer y los formularios reactivos, donde las señales y RxJS se encuentran a diario.