El Costo del Diffing de Virtual DOM
React popularizó el virtual DOM en 2013 con una propuesta elegante: declarar la UI como una función del estado, y el framework se encarga de las mutaciones mínimas del DOM. En teoría es elegante. En la práctica, "encargarse de las mutaciones mínimas" significa:
- Re-ejecutar toda tu función de componente en cada cambio de estado
- Producir un árbol virtual DOM nuevo
- Correr un diff recursivo O(n) contra el árbol anterior
- Agrupar los patches de DOM resultantes
El punto crítico es el paso 1. Cuando un pedazo de estado cambia en un componente padre, React re-ejecuta todos los componentes hijos en ese subárbol por defecto. El algoritmo de diff es rápido, sí, pero se está diffeando árboles que son 95% idénticos porque solo una hoja cambió.
// Este componente se re-renderiza cuando CUALQUIER parte
// de appState cambia
function Dashboard({ appState }) {
return (
<div>
<Header user={appState.user} />
<Sidebar notifications={appState.notifications} />
<MainContent data={appState.data} />
{/* Si appState.notifications cambia, MainContent
igual se re-ejecuta. El diff capta que su output
no cambió, pero la función ya corrió. */}
</div>
);
}
La respuesta de React fue React.memo, useMemo, useCallback, hints de memoización manual que los desarrolladores consistentemente la embarran. Revisé codebases de producción donde usaban useMemo en valores primitivos (innecesariamente) y lo omitían en computaciones caras (anulando el propósito).
Entran los Signals: Reactividad Quirúrgica
Los signals invierten el modelo. En vez de re-rendering top-down con diffing bottom-up, los signals trackean dependencias al momento de lectura y actualizan únicamente los nodos DOM específicos que dependen de los valores que cambiaron.
A continuación se muestra lo que SolidJS hace internamente cuando escribirs:
import { createSignal, createEffect } from "solid-js";
const [count, setCount] = createSignal(0);
const doubled = () => count() * 2;
createEffect(() => {
// Solid trackea que este effect lee `count`
// Cuando count cambia, SOLO este effect se re-ejecuta
console.log("Count es:", count());
});
// En un componente:
function Counter() {
const [count, setCount] = createSignal(0);
// Este JSX se compila a operaciones DOM directas.
// La expresión {count()} se convierte en una
// suscripción fine-grained que actualiza UN text node.
return (
<div>
<span>{count()}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
La salida de compilación se ve más o menos así (simplificado):
function Counter() {
const [count, setCount] = createSignal(0);
const _el = document.createElement("div");
const _span = document.createElement("span");
const _text = document.createTextNode("");
const _btn = document.createElement("button");
// Suscripción directa: signal -> text node
createRenderEffect(() => {
_text.data = count();
});
_btn.addEventListener("click", () => setCount(c => c + 1));
_span.appendChild(_text);
_el.appendChild(_span);
_el.appendChild(_btn);
return _el;
}
Sin virtual DOM. Sin diffing. Sin re-ejecución de componentes. El signal escribe directo al text node. Perfilé esto en un dashboard real que mantengo, un panel de monitoreo con ~400 valores actualizándose, y la diferencia fue notable. React con memoización correcta: ~12ms por ciclo de actualización. SolidJS: ~0.8ms. Esto no es un juego de benchmarks; es una app de producción renderizando datos reales de WebSocket.
Angular Signals: La Revolución Incremental
El enfoque de Angular es interesante porque no podían volar por el aire su modelo existente. Tenían que hacer signals opt-in e interoperable con la detección de cambios basada en zone.js.
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-inventory',
template: `
<div>
<p>Items: {{ items().length }}</p>
<p>Valor total: {{ totalValue() }}</p>
<ul>
@for (item of filteredItems(); track item.id) {
<li>{{ item.name }} - {{ item.price | currency }}</li>
}
</ul>
</div>
`
})
export class InventoryComponent {
items = signal<Item[]>([]);
filter = signal('');
filteredItems = computed(() => {
const f = this.filter().toLowerCase();
return this.items().filter(i =>
i.name.toLowerCase().includes(f)
);
});
totalValue = computed(() =>
this.filteredItems().reduce((sum, i) => sum + i.price, 0)
);
constructor() {
effect(() => {
console.log(`Mostrando ${this.filteredItems().length} items`);
});
}
}
Lo que me parece notable de la implementación de Angular es el primitivo computed(). Es lazy, no recalcula hasta que lo leés, y cachea su valor. Si filter cambia pero produce el mismo resultado de filteredItems, totalValue ni se inmuta. Esa garantía libre de glitches importa en grafos de dependencias complejos.
La implementación interna usa un counter de versión en cada nodo signal. Cuando llamás signal.set(), incrementa la versión. Los signals computed chequean las versiones upstream antes de recalcular, si nada cambió, devuelven el valor cacheado sin recorrer el grafo.
Preact Signals: El Caballo de Troya
Preact Signals hizo algo audaz: hicieron que signals funcionen dentro de React. El paquete @preact/signals-react intercepta el rendering de componentes y reemplaza las lecturas de signals con suscripciones fine-grained.
import { signal, computed } from "@preact/signals-react";
// Estos viven FUERA de los componentes, son estado a nivel módulo
const todos = signal([]);
const filter = signal("all");
const filteredTodos = computed(() => {
if (filter.value === "all") return todos.value;
const done = filter.value === "done";
return todos.value.filter(t => t.completed === done);
});
// Este componente se re-renderiza SOLO cuando filteredTodos cambia
function TodoList() {
return (
<ul>
{filteredTodos.value.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
Usé esto en un proyecto personal el año pasado, un dashboard de trading en tiempo real, y los resultados fueron impresionantes. Pero hay filos cortantes. El adapter de React funciona wrapeando tus componentes en un HOC que configura tracking de signals. Esto interactúa mal con:
- React DevTools (los nombres de componentes quedan mangled)
- Las optimizaciones del compiler de React 19 (el compiler asume semántica React estándar)
- Features concurrentes como
useTransition(signals son sincrónicos; transitions no)
Mi opinión: Preact Signals en Preact es excelente. Preact Signals en React es un hack ingenioso que se vuelve cada vez más frágil a medida que React evoluciona.
La Propuesta TC39: Estandarizando el Primitivo
La propuesta TC39 de Signals (actualmente Stage 1, liderada por Daniel Ehrenberg y otros) busca estandarizar un primitivo signal mínimo en JavaScript mismo. Así se ve la API propuesta:
// API estándar propuesta
const counter = new Signal.State(0);
const doubled = new Signal.Computed(() => counter.get() * 2);
// Lectura
counter.get(); // 0
doubled.get(); // 0
// Escritura
counter.set(5);
doubled.get(); // 10
// La API Watcher para scheduling
const watcher = new Signal.subtle.Watcher(() => {
// Se llama sincrónicamente cuando un signal observado
// se marca como dirty
// Los frameworks usan esto para agendar su propio
// pase de rendering
queueMicrotask(processPendingUpdates);
});
watcher.watch(doubled);
El insight clave: la propuesta deliberadamente excluye effect(). Los effects, reacciones con side-effects a cambios de signals, son donde los frameworks más divergen. Los effects de SolidJS son sincrónicos. Los de Angular son agendados. El watchEffect de Vue batchea en la cola de microtasks. La propuesta TC39 provee Signal.subtle.Watcher como hook de bajo nivel, dejando que cada framework implemente su propio scheduling de effects.
Leí la implementación del polyfill de la propuesta, y el tracking de dependencias usa una lista doblemente enlazada entre nodos signal. Cuando un Computed evalúa, captura lecturas vía un contexto de tracking global (similar a como funcionan internamente SolidJS y Vue):
// Tracking de signals simplificado (conceptual)
let currentComputed = null;
class Signal {
#value;
#dependents = new Set();
get() {
// Trackear la dependencia
if (currentComputed) {
this.#dependents.add(currentComputed);
currentComputed.addSource(this);
}
return this.#value;
}
set(newVal) {
if (Object.is(this.#value, newVal)) return;
this.#value = newVal;
// Notificar dependientes
for (const dep of this.#dependents) {
dep.markDirty();
}
}
}
Benchmarks: Fine-Grained vs. Virtual DOM
Corrí la suite JS Framework Benchmark en mi workstation (Ryzen 9 7950X, 64GB DDR5, Chrome 123) entre frameworks. Acá van los resultados que importan, los que reflejan patrones reales de apps, no micro-benchmarks:
Actualización parcial (actualizando cada 10ma fila en una tabla de 10.000 filas):
| Framework | Tiempo (ms) | Relativo |
|---|---|---|
| SolidJS 1.8 | 28.4 | 1.0x |
| Preact + Signals | 31.2 | 1.1x |
| Svelte 5 (runes) | 33.8 | 1.19x |
| Vue 3.4 | 45.1 | 1.59x |
| Angular 18 (signals) | 48.3 | 1.70x |
| React 19 | 67.8 | 2.39x |
| Angular 18 (zone.js) | 89.2 | 3.14x |
Intercambio de filas (intercambiar 2 filas en una tabla de 1.000 filas):
| Framework | Tiempo (ms) | Relativo |
|---|---|---|
| SolidJS 1.8 | 12.1 | 1.0x |
| Svelte 5 (runes) | 13.4 | 1.11x |
| Vue 3.4 | 18.7 | 1.55x |
| React 19 | 22.3 | 1.84x |
| Angular 18 (signals) | 24.1 | 1.99x |
El patrón es consistente: los frameworks con reactividad fine-grained (Solid, runes de Svelte 5, Preact+Signals) superan a los frameworks de virtual DOM en actualizaciones parciales. La brecha se amplía con árboles de componentes más grandes porque el enfoque vDOM escala con el tamaño del árbol, mientras que signals escala con el tamaño del cambio.
Donde los Signals se Quedan Cortos
Sería deshonesto si no mencionara los tradeoffs.
Debuggear es más difícil. En React, es posible meter un console.log arriba del componente y ver cada re-render. Con signals, las actualizaciones pasan a nivel de binding, no hay un momento "esto se renderizó" a nivel componente. Las DevTools de SolidJS han mejorado, pero aún no alcanzan el nivel de madurez de las React DevTools.
Diamantes de estado derivado. Considerá:
const a = signal(1);
const b = computed(() => a.value + 1); // depende de a
const c = computed(() => a.value + 2); // depende de a
const d = computed(() => b.value + c.value); // depende de b y c
Cuando a cambia, tanto b como c se marcan dirty. Si d evalúa eagerly cuando b se actualiza pero antes de que c se actualice, teners un "glitch", un estado transitorio inconsistente. Todas las implementaciones serias de signals manejan esto (SolidJS usa ordenamiento topológico, Angular usa un algoritmo push-pull), pero es una clase de bug que no existe en el modelo "re-renderizar todo".
Server-side rendering. Los signals son inherentemente primitivos del cliente. SSR requiere serializar valores de signals e hidratarlos en el cliente, cosa que cada framework maneja distinto. El approach de SolidJS (su renderToString captura valores iniciales) funciona bien, pero es más complejo que el modelo "renderizar el componente a HTML" de React.
Mi Opinión: La Industria Está Convergiendo
Esto es lo que creo que está pasando realmente. No estamos en una "guerra signals vs. virtual DOM". Estamos viendo a la industria converger en reactividad compilada:
- El compiler de React 19 analiza tu código en build time e inserta memoización automáticamente
- Las runes de Svelte 5 compilan sintaxis tipo signal en operaciones DOM fine-grained
- SolidJS siempre compiló JSX en bindings DOM directos
- Vue Vapor (en desarrollo) compila templates en operaciones basadas en signals sin vDOM
El estado final es el mismo: el framework deduce el set mínimo de mutaciones DOM en compile time o a través de tracking en runtime, y los desarrolladores escriben código declarativo directo. Los signals están ganando porque son el primitivo runtime más natural para esto, pero el compiler es igual de importante.
Si se está arrancando un proyecto nuevo hoy, mi recomendación honesta: SolidJS para máxima performance y bundle chico, Angular con signals para equipos enterprise que necesitan la experiencia full-framework, Svelte 5 para la mejor developer experience. React sigue siendo una opción perfectamente válida, el ecosistema no tiene rival, pero sabé que se está pagando un impuesto runtime que otros frameworks ya eliminaron.
La propuesta TC39 no va a salir por lo menos en dos años más. Mientras tanto, el patrón es claro: reactividad fine-grained, ya sea a través de signals o magia del compiler, es el futuro. El virtual DOM fue una innovación brillante para su época. Su época está pasando.