De callbacks a Signals: la reactividad real del frontend
Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.
Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.
Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.
Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.
Era 1: callbacks manuales y el DOM que se te olvida sincronizar
En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.
Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.
let count = 0;
const counterEl = document.querySelector(39;#counter39;);
const totalEl = document.querySelector(39;#total39;);
const shippingMsgEl = document.querySelector(39;#shipping-msg39;);
document.querySelector(39;#add-btn39;).addEventListener(39;click39;, () => {
count++;
counterEl.textContent = count;
totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
shippingMsgEl.textContent = count >= 5
? 39;¡Envío gratis!39;
: `Añade ${5 - count} más para envío gratis`;
});
document.querySelector(39;#remove-btn39;).addEventListener(39;click39;, () => {
count = Math.max(0, count - 1);
counterEl.textContent = count;
totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
// shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
});
Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.
No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.
La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.
El problema aparece en cuanto el estado deja de ser trivial:
- Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
- El estado vive disperso: a veces en el DOM (
el.textContent), a veces en variables sueltas, a veces en atributosdata-*. - Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada
addEventListenersin suremoveEventListeneres un memory leak esperando a pasar factura.
Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.
Era 2: Virtual DOM y el modelo declarativo
React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.
function Counter() {
const [count, setCount] = useState(0);
const total = (count * 19.99).toFixed(2);
const shippingMsg = count >= 5
? 39;¡Envío gratis!39;
: `Añade ${5 - count} más para envío gratis`;
return (
<div>
<p>{count}</p>
<p>${total}</p>
<p>{shippingMsg}</p>
<button => setCount(c => c + 1)}>Añadir</button>
<button => setCount(c => Math.max(0, c - 1))}>Quitar</button>
</div>
);
}
El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.
Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.
Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.
En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.
La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.
Era 3: reactividad fina — el grafo en vez del árbol
Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.
import { Component, signal, computed, effect } from 39;@angular/core39;;
@Component({
selector: 39;app-cart-counter39;,
template: `
<p>{{ count() }}</p>
<p>${{ total() }}</p>
<p>{{ shippingMsg() }}</p>
<button (click)="count.set(count() + 1)">Añadir</button>
<button (click)="count.set(count() - 1)">Quitar</button>
`,
})
export class CartCounterComponent {
count = signal(0);
total = computed(() => (this.count() * 19.99).toFixed(2));
shippingMsg = computed(() =>
this.count() >= 5
? 39;¡Envío gratis!39;
: `Añade ${5 - this.count()} más para envío gratis`
);
constructor() {
effect(() => {
console.log(`Carrito: ${this.count()} items — $${this.total()}`);
});
}
}
Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.
Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.
Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.
Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.
Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.
En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.
Los tres paradigmas, uno al lado del otro
| Modelo mental | Cómo detecta cambios | Granularidad de la actualización | Coste computacional | Dónde brilla | |
|---|---|---|---|---|---|
| Callbacks (jQuery / DOM imperativo) | Tú mutas el DOM a mano, evento por evento | No detecta nada — el developer decide cuándo actualizar | La que tú programes, elemento por elemento | Bajo por operación, alto en mantenimiento y bugs de sincronización | Widgets aislados, prototipos, páginas sin estado compartido |
| Virtual DOM (React) | La UI es una función pura del estado | Diffing — compara árbol virtual anterior vs. nuevo | Por componente/subárbol, tras re-ejecutar y comparar | Re-ejecuta la función de render completa y diffea en cada cambio | Apps con estado complejo, equipos grandes, ecosistema maduro |
| Signals (Angular, Solid, Svelte) | Grafo de dependencias reactivas | Suscripción directa — el signal sabe quién lo consume | El binding o nodo exacto del DOM que depende del valor | Solo se ejecuta lo que realmente cambió | UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento |
Por qué la reactividad fina no es una moda
Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.
Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.
El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.
Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?
Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.
Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.
Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.
Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.
Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.
Preguntas frecuentes sobre programación reactiva en el frontend
¿Qué es la programación reactiva?
Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.
¿El Virtual DOM está muerto?
No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.
¿Los Signals reemplazan a React?
No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.
¿Qué es la reactividad fina (fine-grained reactivity)?
Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.
¿Angular usa Virtual DOM?
No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.
¿Debo migrar mi app de React a Signals?
No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
