Signals y estado reactivo

Por: Artiko
angularbuenas-practicassignalsestadocomputedresourcerendimiento

Signals y estado reactivo

El estado es el lugar donde las aplicaciones se pudren. Un valor que se calcula en tres sitios, un boolean que quedó desincronizado de la lista que describe, un subscribe que actualiza otro subscribe: casi todos los bugs difíciles de una app Angular son bugs de estado, no de UI. Los signals existen para atacar exactamente ese problema, y desde Angular 22 son la forma por defecto de manejar estado reactivo local.

Pero un signal mal usado reproduce los mismos males con sintaxis nueva. Este capítulo no te enseña qué es signal() —eso ya lo sabés—, sino cómo pensar el grafo reactivo para que el estado sea derivable, memoizado y barato de recalcular, y cuáles son los antipatrones que lo convierten en un EventEmitter disfrazado.

El modelo mental: un grafo de dependencias

La idea central es que tu estado forma un grafo dirigido. En la raíz hay valores que se escriben a mano (writable signals). De ellos cuelgan valores derivados que nunca se escriben, solo se calculan (computed). Y en las hojas hay efectos: código que produce cambios fuera del grafo (DOM imperativo, console, localStorage) cuando algo aguas arriba cambia.

flowchart LR
  subgraph Fuentes["Fuentes (writable)"]
    A["signal: items"]
    B["signal: filtro"]
  end
  subgraph Derivados["Derivados (computed, memoizados)"]
    C["computed: itemsFiltrados"]
    D["computed: total"]
  end
  subgraph Efectos["Efectos (hojas)"]
    E["effect: persistir en localStorage"]
    T["Template (consumidor)"]
  end
  A --> C
  B --> C
  C --> D
  C --> T
  D --> T
  D --> E

Tres reglas gobiernan este grafo y explican todo lo demás:

  1. computed es pull-based, lazy y memoizado. No calcula nada hasta que alguien lee su valor. Cuando lo lee, si ninguna de sus dependencias cambió desde la última lectura, devuelve el valor cacheado sin volver a ejecutar la función. Esto es lo que evita el trabajo redundante.
  2. La actualización es en dos fases. Escribir un signal no recalcula nada de inmediato: solo marca como sucios (dirty) los nodos que dependen de él. El recálculo ocurre cuando se lee (los computed) o cuando el planificador corre los efectos.
  3. Las dependencias se rastrean en tiempo de ejecución. No declarás de qué depende un computed: Angular lo detecta viendo qué signals leíste durante su ejecución. Si un if hace que en esta corrida no leas un signal, deja de ser dependencia hasta que vuelvas a leerlo.

signal(): la fuente de verdad

Un writable signal es un contenedor con set(), update() y lectura por invocación (mi()). La clave de rendimiento vive en su función de igualdad:

import { signal } from '@angular/core';

const contador = signal(0);
contador.set(5);
contador.update(n => n + 1); // 6

// Por defecto usa Object.is para decidir si el valor "cambió".
// Si set() recibe un valor Object.is-igual al actual, NO notifica a los dependientes.
contador.set(6); // no-op reactivo: nadie se entera

Ese detalle importa: si set() recibe un valor igual (según Object.is), no propaga nada. Para objetos y arrays, Object.is compara referencias, lo que tiene una consecuencia que veremos en los antipatrones: mutar un objeto sin cambiar su referencia no dispara ninguna actualización.

computed(): estado derivado sin costo repetido

Todo valor que se deduce de otros valores debe ser un computed, no un signal que mantenés sincronizado a mano. La memoización es la ventaja algorítmica:

import { signal, computed } from '@angular/core';

const items = signal<Producto[]>([]);
const filtro = signal('');

const itemsFiltrados = computed(() =>
  items().filter(p => p.nombre.includes(filtro()))
);

const total = computed(() =>
  itemsFiltrados().reduce((acc, p) => acc + p.precio, 0)
);

Si el template lee itemsFiltrados() y total() cinco veces por render, el filtro (O(n)) y el reduce (O(n)) se ejecutan una sola vez mientras items y filtro no cambien; las otras cuatro lecturas devuelven el valor cacheado en O(1). Además total solo se recalcula si itemsFiltrados produjo un array distinto: si cambiás filtro a un valor que da el mismo subconjunto, la memoización corta la propagación antes de llegar al reduce. Ese es el “trabajo redundante evitado” del que habla la teoría, hecho concreto.

effect(): para efectos, no para derivar

Un effect corre una función cuando cambian los signals que lee, y vuelve a correr solo si alguna dependencia cambió de versión (internamente Angular compara versiones antes de ejecutar). Su lugar legítimo son los efectos secundarios que salen del grafo:

import { effect } from '@angular/core';

// Legítimo: sincronizar estado reactivo con algo externo.
effect(() => {
  localStorage.setItem('carrito', JSON.stringify(items()));
});

// Legítimo: logging/analytics, integrar una librería imperativa (charts, mapas).
effect(() => chart.setData(datosGrafico()));

// Limpieza cuando el effect se re-ejecuta o se destruye.
effect((onCleanup) => {
  const id = setInterval(() => refrescar(), 1000);
  onCleanup(() => clearInterval(id));
});

Cuándo NO usar effect: si el objetivo del effect es calcular un valor y guardarlo en otro signal, estás usando la herramienta equivocada. Eso es un computed. La regla operativa: si el resultado se queda dentro del grafo de signals, es computed; si sale del grafo, es effect. Volveremos sobre esto en los antipatrones porque es el error número uno.

Nota de versión: en Angular 22 los effects pueden escribir signals sin configuración especial (la vieja opción allowSignalWrites ya no existe). Que puedas no significa que convenga: escribir signals desde un effect es la fuente principal de loops, como se ve más abajo.

linkedSignal(): writable pero derivado

A veces necesitás un valor que normalmente se deriva de otro, pero que el usuario también puede sobrescribir, y que se resetea cuando la fuente cambia. El caso clásico: un <select> cuya opción seleccionada por defecto es la primera de la lista, pero el usuario puede elegir otra; si la lista cambia, la selección vuelve al default. Un computed no sirve (no es writable) y un signal + effect genera el loop del antipatrón. Para eso existe linkedSignal (estable desde Angular 20):

import { signal, linkedSignal } from '@angular/core';

const opciones = signal<string[]>(['envío estándar', 'exprés']);

// Se inicializa y se RESETEA con la fuente, pero es writable.
const seleccion = linkedSignal(() => opciones()[0]);

seleccion.set('exprés');        // el usuario elige otra
opciones.set(['retiro', 'exprés']); // cambia la fuente → seleccion vuelve a 'retiro'

La forma avanzada te da acceso al valor previo, útil para preservar la selección si sigue siendo válida tras el cambio de fuente:

const seleccion = linkedSignal<string[], string>({
  source: () => opciones(),
  computation: (nuevasOpciones, previo) =>
    previo && nuevasOpciones.includes(previo.value)
      ? previo.value            // preserva la elección si aún existe
      : nuevasOpciones[0],      // si no, resetea al default
});

untracked(): leer sin crear dependencia

Dentro de un computed o un effect, todo signal que leas se vuelve dependencia. A veces querés leer un valor sin que dispare re-ejecuciones. untracked() corre una función fuera del contexto reactivo:

import { effect, untracked } from '@angular/core';

effect(() => {
  const actual = valorObservado();       // SÍ es dependencia
  const cfg = untracked(() => config());  // NO es dependencia: solo lo consulto
  registrar(actual, cfg);
});

Sin untracked, un effect que quería reaccionar solo a valorObservado también se dispararía con cada cambio de config. Usalo para desacoplar “a qué reacciono” de “qué leo”.

Estado asíncrono: resource y httpResource

Los signals modelan estado síncrono. Para estado asíncrono derivado —datos que dependen de otros signals y hay que ir a buscar— Angular ofrece la familia resource. La idea: un params reactivo (signals) que, al cambiar, dispara un loader asíncrono, y el resultado se expone como signals de valor y de estado.

import { resource, signal } from '@angular/core';

const usuarioId = signal(1);

const usuario = resource({
  params: () => usuarioId(),                    // reactivo: cambia → recarga
  loader: async ({ params, abortSignal }) => {  // abortSignal cancela la anterior
    const res = await fetch(`/api/users/${params}`, { signal: abortSignal });
    return res.json() as Promise<Usuario>;
  },
});

// Signals expuestos:
usuario.value();     // el dato (o undefined según defaultValue)
usuario.status();    // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error'
usuario.isLoading(); // boolean
usuario.error();     // el error si status === 'error'
usuario.hasValue();  // narrowing: si es true, value() no es undefined
usuario.reload();    // fuerza recarga (no-op si ya está cargando)

Detalles que definen el buen uso:

import { httpResource } from '@angular/common/http';

const q = signal('');
const resultados = httpResource<Producto[]>(() =>
  q() ? `/api/buscar?q=${q()}` : undefined // undefined → no dispara (idle)
);

resource no reemplaza a un cliente de datos completo con caché compartida entre componentes (para eso está TanStack Query o NgRx, capítulo 13); es la herramienta correcta para estado asíncrono local a un componente o feature, derivado de signals.

Gestión de estado sin librería: el signal store casero

Para la mayoría de las features no necesitás NgRx. Un service con signals cubre el 80% de los casos con menos ceremonia. El patrón tiene una regla de encapsulamiento no negociable: los writable signals son privados; hacia afuera solo exponés readonly y computed. Así el estado solo cambia por métodos con nombre (intención explícita), nunca por un .set() desde cualquier componente.

import { Injectable, computed, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CarritoStore {
  // Estado privado y writable: nadie fuera del store lo toca.
  readonly #items = signal<LineaCarrito[]>([]);
  readonly #cupon = signal<Cupon | null>(null);

  // Vistas públicas: solo lectura y derivadas.
  readonly items = this.#items.asReadonly();
  readonly cantidad = computed(() =>
    this.#items().reduce((n, l) => n + l.cantidad, 0)
  );
  readonly subtotal = computed(() =>
    this.#items().reduce((s, l) => s + l.precio * l.cantidad, 0)
  );
  readonly total = computed(() => {
    const desc = this.#cupon()?.porcentaje ?? 0;
    return this.subtotal() * (1 - desc / 100);
  });

  // Mutaciones: únicos puntos de escritura, con nombre e intención.
  agregar(producto: Producto): void {
    this.#items.update(items => {
      const existe = items.find(l => l.id === producto.id);
      return existe
        ? items.map(l => l.id === producto.id ? { ...l, cantidad: l.cantidad + 1 } : l)
        : [...items, { id: producto.id, precio: producto.precio, cantidad: 1 }];
    });
  }

  quitar(id: string): void {
    this.#items.update(items => items.filter(l => l.id !== id));
  }

  aplicarCupon(cupon: Cupon): void { this.#cupon.set(cupon); }
}

Los componentes consumen store.total() y llaman store.agregar(...); jamás ven un writable signal. Esto respeta el SRP del capítulo 3: el componente presenta, el store guarda la verdad y las reglas de derivación.

Store por feature, no un mega-store global. Un store gigante con todo el estado de la app acopla features que no tienen relación y complica el testing. Preferí un store por feature (CarritoStore, CatalogoStore), y si dos features comparten datos, que uno dependa del otro por inyección, no que compartan un blob mutable. providedIn: 'root' para estado de aplicación; provisto en el componente de ruta para estado con el ciclo de vida de esa pantalla.

flowchart TD
  U["Componente (UI)"] -->|"agregar() / quitar()"| M["Métodos del store"]
  M -->|"set / update"| W["#items, #cupon (writable, privados)"]
  W -->|"marca dirty"| C["computed: subtotal, total (memoizados)"]
  C -->|"lectura pull"| U
  W -.->|asReadonly| RO["items (readonly público)"]
  RO --> U

Reglas de rendimiento

El costo de los signals no está en leerlos —eso es barato— sino en cuánto trabajo dispara cada escritura y cuántas veces recalculás lo mismo. Cuatro reglas:

1. Granularidad: signals chicos, no un objeto monolítico. Un solo signal con { filtro, orden, pagina, ... } obliga a que cualquier cambio recree el objeto entero y notifique a todos los que dependen de cualquier campo. Separar en signals independientes hace que cambiar pagina no invalide los computed que solo dependen de filtro.

2. equal personalizado para cortar propagación. Por defecto los objetos comparan por referencia, así que un computed que produce un objeto “igual pero nuevo” propaga igual. Si sabés cómo comparar semánticamente, pasá equal:

const puntos = computed(
  () => calcularPuntos(datos()),
  { equal: (a, b) => a.x === b.x && a.y === b.y } // no propaga si x,y no cambian
);

Esto detiene la cascada cuando el valor “cambió de referencia pero no de contenido relevante”, evitando recomputar todo lo que cuelga aguas abajo.

3. Dejá que computed haga el trabajo caro, una vez. Si tenés un filter().sort().map() costoso (O(n log n)), ponelo en un computed y leelo donde haga falta. Repetir esa cadena en el template o en varios métodos la ejecuta varias veces por render; en un computed se ejecuta una sola vez y se cachea.

4. Cuidado con el @for sobre un computed que crea arrays nuevos. Aunque el computed esté memoizado, si su lógica genera objetos nuevos en cada recálculo legítimo, el @for necesita un track estable (por id) para no destruir y recrear el DOM. Es la bisagra con el capítulo 6 sobre change detection.

Antipatrones de signals

1. effect() para derivar estado (el peor)

El error más común: usar un effect para calcular un valor y guardarlo en otro signal.

// ANTIPATRÓN: derivar con effect.
readonly items = signal<Producto[]>([]);
readonly total = signal(0);

constructor() {
  effect(() => {
    // Corre DESPUÉS del cambio (un tick tarde), rompe la memoización,
    // y 'total' puede quedar transitoriamente desincronizado de 'items'.
    this.total.set(this.items().reduce((s, p) => s + p.precio, 0));
  });
}
// MEJOR: derivar con computed.
readonly items = signal<Producto[]>([]);
readonly total = computed(() =>
  this.items().reduce((s, p) => s + p.precio, 0)
); // síncrono, memoizado, imposible desincronizar.

El computed es síncrono (no hay ventana donde total esté viejo), es memoizado (no recalcula si items no cambió) y no puede quedar desincronizado porque no almacena nada: se deriva al leerse. El effect pierde las tres propiedades.

2. effect() que escribe un signal del que depende (loop)

// ANTIPATRÓN: loop de retroalimentación.
effect(() => {
  const n = contador();
  contador.set(n + 1); // se lee y se escribe → se re-dispara a sí mismo
});

Escribir dentro de un effect un signal que el mismo effect lee crea un ciclo. Aunque Angular tenga protecciones, el patrón es frágil por diseño. Si necesitás actualizar B cuando cambia A, casi siempre lo correcto es computed (si B se deriva) o linkedSignal (si B es derivado pero writable). Si de verdad tenés que escribir en un effect, leé lo mínimo con untracked para no incluirte a vos mismo en las dependencias.

3. Mutar el objeto sin cambiar la referencia

// ANTIPATRÓN: mutación in situ.
const usuario = signal({ nombre: 'Ana', roles: ['user'] });
usuario().roles.push('admin'); // muta el array interno...
usuario.set(usuario());        // ...y set con la MISMA referencia: Object.is dice "igual" → nadie se entera
// MEJOR: nueva referencia con update.
usuario.update(u => ({ ...u, roles: [...u.roles, 'admin'] }));

Como set compara con Object.is y la referencia no cambió, no se propaga nada: el template no se actualiza. La regla es inmutabilidad: cada cambio produce un objeto/array nuevo.

4. Exponer setters públicos desde el store

// ANTIPATRÓN: estado writable expuesto.
@Injectable({ providedIn: 'root' })
export class SesionStore {
  readonly usuario = signal<Usuario | null>(null); // público y writable
}
// Cualquier componente: sesion.usuario.set(otroUsuario) → mutaciones sin control ni trazabilidad.
// MEJOR: writable privado, readonly público, mutación por método.
@Injectable({ providedIn: 'root' })
export class SesionStore {
  readonly #usuario = signal<Usuario | null>(null);
  readonly usuario = this.#usuario.asReadonly();
  iniciarSesion(u: Usuario): void { this.#usuario.set(u); }
  cerrarSesion(): void { this.#usuario.set(null); }
}

Un writable público es un estado global mutable desde cualquier parte: imposible de razonar y de auditar quién lo cambia.

5. Sobrecarga de effects

Muchos effects chicos que escriben signals convierten tu grafo declarativo en un enjambre de callbacks imperativos —el mismo problema de los subscribe anidados que RxJS mal usado producía—. Antes de escribir un effect, preguntate: ¿esto deriva un valor?computed. ¿esto sincroniza con la fuente y es writable?linkedSignal. ¿esto va a buscar datos async?resource. El effect es el último recurso, solo para lo que sale del mundo de los signals.

flowchart TD
  Q{"¿Qué necesito?"} --> D{"¿Deriva un valor<br/>de otros signals?"}
  D -->|Sí, solo lectura| C["computed"]
  D -->|Sí, pero writable/reseteable| L["linkedSignal"]
  D -->|No| A{"¿Es async<br/>derivado de signals?"}
  A -->|Sí| R["resource / httpResource"]
  A -->|No, efecto externo<br/>DOM/log/storage| E["effect"]

Cuándo signals y cuándo RxJS

Los signals son el default para estado: valores que existen “ahora” y que leés de forma síncrona (el carrito, el filtro, el usuario logueado). RxJS sigue siendo la herramienta para eventos y flujos en el tiempo: streams que necesitan debounce, retry, cancelación compleja, combinación de múltiples fuentes asíncronas, WebSockets. La regla corta: si te importa el valor actual, signal; si te importa la secuencia de valores y su timing, Observable. Y no hace falta elegir de manera excluyente: toSignal y toObservable cruzan el puente en ambos sentidos. El criterio completo —qué operadores valen la pena, cómo evitar memory leaks con takeUntilDestroyed, y la interoperabilidad fina— es el tema del próximo capítulo.

Checklist

Con el estado bajo control y el grafo de signals limpio, queda decidir la otra mitad de la reactividad: cuándo un flujo de eventos pide RxJS y cómo convivir con signals sin duplicar estado ni filtrar suscripciones. Seguimos en RxJS con criterio →.