El grafo reactivo de Angular: cómo Signals sabe qué recalcular

grafo reactivo de Angular — Dominicode

Written by

in

,

Un junior del equipo me enseñó hace poco un computed() que calculaba el total de un carrito. Funcionaba. Pero me dijo una frase que lo delata todo: “le metí un console.log dentro y no se imprime cuando cambio la cantidad… hasta que abro el modal del total”.

No estaba roto. Estaba haciendo exactamente lo que debe hacer.

El problema no era su código. Era su modelo mental. No conocía el grafo reactivo de Angular, la estructura que decide qué se recalcula y cuándo. Pensaba que un computed() se recalcula cuando cambian sus datos. Y no. Se recalcula cuando alguien lo lee. Esa diferencia, que parece un detalle, es la puerta de entrada a entender cómo piensa Angular por dentro.

Porque eso es justo lo que vive debajo de signal(), computed() y effect(): un grafo que casi nadie se molesta en entender, y que lo explica todo.

¿Qué es el grafo reactivo de Angular?

El grafo reactivo de Angular es la estructura interna que el framework construye con su sistema de Signals para saber, en todo momento, qué valores dependen de qué otros. No es una API que tú llamas. Es el motor que se monta solo cuando declaras signals, computeds y effects, y es lo que permite que Angular recalcule únicamente lo que cambió en lugar de revisar la aplicación entera.

Los Signals son estables desde Angular 16-17 (2023), y son la base sobre la que se apoya el modo zoneless, disponible como opción de producción a partir de Angular v20.

Imagínalo literalmente como un grafo: nodos conectados por flechas. Los nodos son tus valores reactivos. Las flechas son las dependencias entre ellos. Cuando un valor cambia, Angular recorre esas flechas para decidir qué tocar y qué dejar en paz.

Y la clave —la que casi nadie explica— es que esas flechas no las dibujas tú. Las descubre Angular en tiempo de ejecución.

Vamos por partes.

Los nodos: productores y consumidores

Todo en el grafo es una de dos cosas (o las dos a la vez). Te lo presento como modelo conceptual, no como API pública: Angular no te expone estos nombres, pero entenderlos cambia cómo lees tu propio código.

  • Un signal() es un productor puro. Tiene un valor, otros lo leen, pero él no depende de nadie. Es una raíz del grafo.
  • Un computed() es consumidor y productor a la vez. Lee otros signals (consume) y a su vez otros lo leen a él (produce). Es un nodo intermedio.
  • Un effect() es un consumidor puro. Lee signals y reacciona, pero nadie lee a un effect. Es una hoja del grafo, el final de la cadena.
import { signal, computed, effect } from '@angular/core';

const precio = signal(100);          // productor puro (raíz)
const cantidad = signal(2);          // productor puro (raíz)

const total = computed(() =>         // consumidor (lee precio y cantidad)
  precio() * cantidad());            // + productor (otros leerán 'total')

effect(() => {                       // consumidor puro (hoja)
  console.log('Total actual:', total());
});

El grafo aquí tiene una forma clarísima: precio y cantidad apuntan a total, y total apunta al effect. Cuatro nodos, tres flechas.

Pero tú no escribiste ni una sola de esas flechas.

Las aristas: tracking dinámico de dependencias

Aquí está la primera idea que separa a quien usa signals de quien los entiende.

Las dependencias no se declaran. Angular las descubre.

Cuando un computed() o un effect() se ejecuta, Angular activa un registro temporal: “todo signal que se lea durante esta ejecución se anota como dependencia”. Lees precio() dentro del computed → se crea la flecha precio → total. Lees cantidad() → se crea cantidad → total. Termina la ejecución, se cierra el registro.

Esto tiene una consecuencia preciosa: las dependencias pueden ser condicionales. Cada ejecución puede producir un conjunto distinto de aristas.

const modoOscuro = signal(false);
const colorClaro = signal('#ffffff');
const colorOscuro = signal('#1a1a1a');

const colorFondo = computed(() => {
  if (modoOscuro()) {
    return colorOscuro();   // solo se lee si modoOscuro es true
  }
  return colorClaro();      // solo se lee si modoOscuro es false
});

Cuando modoOscuro es false, este computed depende de modoOscuro y de colorClaro. No depende de colorOscuro en absoluto. Si cambias colorOscuro mientras estás en modo claro, colorFondo no se marca como sucio, no se recalcula, no pasa nada.

Cambia modoOscuro a true y, en el siguiente recálculo, el grafo se reconfigura: ahora la flecha sale de colorOscuro y la de colorClaro desaparece.

Esto no lo consigues gratis con RxJS combinando observables. Aquí es el comportamiento por defecto, sin esfuerzo. Es exactamente este tipo de detalle el que trabajamos a fondo en el curso de Angular Moderno, porque entender el grafo cambia cómo estructuras el estado de toda la app.

Push y pull: por qué el computed de mi compañero no se ejecutaba

Volvamos a la historia del principio. El console.log que no se imprimía.

El grafo reactivo funciona con dos fases distintas, y casi todo el mundo solo conoce la primera.

Fase push (cuando cambias un signal). Llamas a cantidad.set(5). Angular recorre el grafo hacia abajo y marca a los consumidores como “sucios” (stale). total se marca sucio. El effect que depende de total se marca sucio. Y ya. No se recalcula nada todavía. Solo se propaga una marca de “esto podría haber cambiado”.

Fase pull (cuando alguien lee). El valor de un computed() solo se recalcula cuando alguien lo lee y está marcado sucio. Es perezoso (lazy) y memoizado: si nadie lo lee, no se ejecuta jamás.

const a = signal(1);
const b = signal(2);

const suma = computed(() => {
  console.log('¡Calculando suma!');   // ¿cuándo se imprime esto?
  return a() + b();
});

a.set(10);
a.set(20);
a.set(30);
// Hasta aquí: el log NO se ha impreso ni una vez.

console.log(suma());  // AHORA imprime "¡Calculando suma!" y luego 32
console.log(suma());  // NO vuelve a imprimir: valor memoizado

Tres cambios en a y cero recálculos, porque nadie leyó suma. La leemos una vez y se calcula una vez. La leemos de nuevo sin cambios de por medio y devuelve el valor cacheado sin recalcular.

Por eso el computed() del carrito “no se ejecutaba” hasta abrir el modal: ningún template estaba leyendo ese valor. En cuanto el modal lo renderizó, lo leyó, y entonces —y solo entonces— se recalculó.

No era un bug. Era el grafo trabajando exactamente como debe: no malgastar ni un ciclo de CPU en valores que nadie está mirando.

Consistencia glitch-free: nunca verás un estado intermedio falso

Pregunta incómoda: ¿qué pasa cuando un nodo depende del mismo origen por dos caminos distintos?

const base = signal(10);

const doble = computed(() => base() * 2);
const triple = computed(() => base() * 3);

const resumen = computed(() => `${doble()} y ${triple()}`);

resumen depende de doble y de triple, y ambos dependen de base. Hay dos rutas desde base hasta resumen.

Cuando cambias base, un sistema reactivo mal diseñado podría recalcular resumen dos veces (una por cada ruta) o, peor, calcularlo con doble ya actualizado pero triple todavía viejo. Eso es un glitch: un estado intermedio que nunca debió existir.

El grafo de Angular es glitch-free. Ante un cambio en base, resumen se recalcula una sola vez, y cuando lo hace, tanto doble como triple ya están coherentes. Nunca observas la mezcla rara. El orden de evaluación del grafo (pull bajo demanda) junto con el versionado de cada nodo garantizan que un consumidor con varias rutas hacia el mismo origen converja en un único recálculo consistente.

Esto importa de verdad en producción. Es la diferencia entre una UI que parpadea con valores intermedios y una que actualiza limpio.

Versiones e igualdad: la poda que ahorra renders

Aquí entra el matiz que convierte el grafo en algo eficiente y no solo correcto.

Cada productor lleva, conceptualmente, una versión. Cuando un consumidor está sucio y va a recalcular, primero compara: “¿la versión de mis dependencias cambió de verdad respecto a la última vez que las usé?”. Si nada cambió realmente, no recomputa.

Y hay una segunda poda, más conocida: la función de igualdad. Por defecto, un signal usa Object.is para decidir si el nuevo valor es distinto del anterior. Si haces set con un valor igual al actual, el grafo no propaga nada aguas abajo.

const estado = signal('activo');

const etiqueta = computed(() => {
  console.log('Recalculando etiqueta');
  return estado().toUpperCase();
});

etiqueta();              // imprime "Recalculando etiqueta" → "ACTIVO"
estado.set('activo');    // mismo valor: Object.is da true → NO propaga
etiqueta();              // NO recalcula: el grafo nunca se marcó sucio

Puedes personalizar esa comparación cuando trabajas con objetos:

const usuario = signal(
  { id: 1, nombre: 'Ana' },
  { equal: (a, b) => a.id === b.id }   // igual si el id no cambia
);

Ahora, si emites un objeto nuevo con el mismo id, el grafo lo considera igual y corta la propagación ahí mismo. Menos recálculos, menos renders. Esta equal es tu palanca para podar el grafo a mano cuando lo necesitas.

Effects y el scheduler: por qué no son síncronos

Un detalle que confunde: los effect() no corren en el instante exacto en que cambias un signal.

Cuando un signal del que depende un effect cambia, el effect se marca sucio y se agenda (scheduler). Angular lo ejecuta de forma agrupada, ligado normalmente a su ciclo de detección de cambios. Esto evita que un effect se dispare diez veces si haces diez set seguidos en la misma tarea: se ejecuta una vez, con el estado final.

const x = signal(0);

effect(() => console.log('x es', x()));

x.set(1);
x.set(2);
x.set(3);
// El effect NO imprime tres veces seguidas.
// Se agenda y corre una vez, con el valor final: "x es 3"

Si vienes de pensar en callbacks síncronos, este es el ajuste mental que necesitas. El effect reacciona, pero reacciona cuando toca, no a cada microcambio.

El contraste que lo explica todo: grafo vs. Zone.js

Ahora la pieza que da sentido a todo lo anterior.

Durante años, Angular detectó cambios con Zone.js + dirty checking. El modelo era de fuerza bruta: cuando algo podía haber cambiado (un click, un timeout, una respuesta HTTP), Angular recorría todo el árbol de componentes comprobando cada binding por si acaso. Funcionaba, pero el framework no sabía qué había cambiado. Solo sabía que algo pudo cambiar, y revisaba entero por si las moscas.

El grafo reactivo invierte el modelo. Angular ya no necesita preguntar “¿cambió algo en alguna parte?”. El propio grafo sabe exactamente qué signal cambió y qué nodos dependen de él. La actualización deja de ser una búsqueda y pasa a ser una notificación dirigida.

Zone.js + dirty checking Grafo reactivo (Signals)
¿Qué sabe el framework? Que algo pudo cambiar Qué signal cambió exactamente
Alcance de la revisión Todo el árbol de componentes Solo el nodo y sus dependientes
Disparo Cualquier evento async (click, timeout, HTTP) El cambio concreto de un signal
Coste Proporcional al tamaño del árbol Proporcional a lo que de verdad cambió
Viabilidad zoneless No (necesita Zone.js) Sí (Angular puede prescindir de Zone.js)

Esto es la base técnica de zoneless —opción de producción desde Angular v20— y de la detección de cambios granular: si todo tu estado vive en signals, Angular puede prescindir de Zone.js por completo, porque el grafo ya le dice qué refrescar. Pasas de “revisa todo el árbol por si acaso” a “actualiza este nodo y sus tres dependientes, nada más”.

Si quieres ver dónde encaja esto en la versión actual, lo cuento en detalle en las novedades de Angular v22, y cómo este mismo grafo gobierna la carga de datos asíncrona en el post sobre la Resource API de Angular 22.

Qué puedes hacer con esto hoy

No necesitas memorizar internals para escribir signals. Pero con este modelo en la cabeza dejas de programar a ciegas:

  1. Mete tu lógica derivada en computed() sin miedo a la performance: si nadie lo lee, no cuesta nada.
  2. Deja de “optimizar” recálculos a mano — el grafo ya memoiza y poda por ti.
  3. Usa equal personalizado cuando trabajes con objetos y veas renders de más.
  4. Mueve estado de RxJS a signals donde la lógica sea síncrona y derivada; reserva RxJS para flujos de eventos reales.

La próxima vez que un computed() “no se ejecute cuando esperabas”, ya no vas a pensar que está roto. Vas a saber que el grafo está esperando, perezoso y eficiente, a que alguien lea el valor.

Si quieres dominar Signals con esta profundidad —el grafo, los effects, la migración desde RxJS y los patrones que aguantan en producción— eso es justo lo que construimos paso a paso en el curso de Angular Moderno. Y si quieres seguir afilando el modelo mental con la comunidad, te espero en Dominicode Labs.

Preguntas frecuentes

¿El grafo reactivo es lo mismo que los Signals?

No exactamente. Los Signals (signal, computed, effect) son las APIs que tú usas; el grafo reactivo es la estructura interna que Angular construye a partir de ellas para saber qué depende de qué. Tú escribes signals; Angular monta el grafo automáticamente por debajo.

¿Necesito entender el grafo reactivo para usar Signals?

Para escribir código que funcione, no. Para escribir código eficiente y entender por qué un computed() se comporta como lo hace —cuándo recalcula, cuándo no, por qué no parpadea— sí. Es la diferencia entre usar signals y dominarlos.

¿El grafo reactivo reemplaza a RxJS?

No lo reemplaza, lo complementa. El grafo de Signals brilla en estado síncrono y valores derivados. RxJS sigue siendo la mejor herramienta para flujos de eventos complejos, streams asíncronos y operadores como debounce o switchMap. Muchos proyectos usan ambos: signals para el estado, RxJS para los flujos.

¿Qué relación tiene con zoneless?

Total. El modo zoneless elimina Zone.js, y solo es viable porque el grafo reactivo ya le dice a Angular exactamente qué cambió y qué refrescar. Sin el grafo, Angular tendría que volver a revisar todo el árbol de componentes. El grafo es la condición que hace posible zoneless.

¿Un computed() se ejecuta siempre que cambian sus datos?

No. Es perezoso: se marca como “sucio” cuando cambia una dependencia, pero solo se recalcula de verdad cuando alguien lee su valor. Si nadie lo lee, no se ejecuta. Y una vez calculado, devuelve un valor memoizado hasta que cambie alguna dependencia.

¿Cómo evita Angular recalcular un valor dos veces ante un mismo cambio?

Gracias a la consistencia glitch-free y al versionado de nodos. Si un consumidor depende de un mismo origen por varias rutas, el grafo lo recalcula una sola vez y con valores coherentes, sin estados intermedios falsos ni recálculos duplicados.

Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *