READMEby ansango
← Volver al libro

Alternativas a React : Vue, Angular, Svelte, Solid, Qwik y signals

Más allá de React: reactividad granular con signals en Vue, Angular Signals, Svelte 5, Solid; resuability de Qwik; comparación coarse-grained vs fine-grained reactivity y la respuesta de React con el compilador Forget

~6 min de lectura
Resumen

Capítulo panorámico: Vue, Angular, Svelte 5, Solid y Qwik, comparados en su modelo de reactividad. La tesis: React no es reactivo en sentido tradicional. Su modelo (re-render del componente) es coarse-grained; signals son fine-grained — solo el subárbol que depende de un valor mutado se actualiza, sin reejecutar el componente completo. El capítulo recorre las primitivas de cada framework (Vue ref/reactive, Angular Signals, Svelte $state/$derived/$effect, Solid createSignal, Qwik resumability) y cierra con la postura oficial del equipo de React ante este enfoque: no se adoptan signals, sino que el compilador Forget automatiza la memoización. Lectura recomendada para entender por qué tu próxima librería favorita decide las cosas como las decide.

El patrón común: reactividad, declaratividad, componentes

Antes de meternos en cada framework, el libro destaca lo que todas comparten:

CaracterísticaCómo la resuelve cada framework
ComponentesFunctional/class-style en todos; se componen igual.
Sintaxis declarativaJSX (React, Solid, Qwik), templates (Vue, Angular), .svelte files (Svelte).
UpdatesvDOM diffing (React) ↔ signals ↔ change detection (Angular) ↔ compilación a código imperativo (Svelte) ↔ resumability (Qwik).
LifecycleHooks (React), onMounted/onUnmounted (Vue), effect runes (Svelte), onMount (Solid), useVisibleTask$ (Qwik).

Y luego están las divergencias, que es donde está la chicha.

Vue.js: reactividad con proxies

Vue (Evan You, ex-Angular) coge la parte buena de AngularJS y la entrega sin su peso. Su modelo es lo que hoy llamamos reactividad fina con signals (aunque en Vue se llaman ref y reactive).

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) { track(target, key); return target[key]; },
    set(target, key, value) { target[key] = value; trigger(target, key); },
  });
}

function ref(value) {
  return {
    get value() { track(refObject, "value"); return value; },
    set value(newValue) { value = newValue; trigger(refObject, "value"); },
  };
}
import { ref, watchEffect } from "vue";

const count = ref(0);

watchEffect(() => {
  document.body.innerHTML = `count is: ${count.value}`;
});

count.value++;     // dispara trigger → ejecuta el watchEffect
Composition API + Vapor Mode

Vue 3 con Composition API ya tiene primitivas estilo signal. Está explorando Vapor Mode (inspirado por Solid) para no pasar por virtual DOM en absoluto.

Angular: change detection y Signals (nuevo)

Angular mantuvo durante mucho tiempo change detection con Zone.js: recorría el árbol de componentes buscando bindings con cada evento asíncrono. Reciente, adoptó Signals como primitiva de reactividad:

const count = signal(0);
count();                       // access
count.set(1);                  // asignación explícita
count.update(v => v + 1);      // derivado del previo

const state = signal({ count: 0 });
state.mutate(o => { o.count++; });   // mutación con identidad compartida
Cómo se accede

Sin auto-unwrapping: acceder a un signal siempre es count(). Es un poco más verboso en lectura, pero a cambio te deja pasar signals crudos como props sin que pierdan reactividad. Es la dirección opuesta al ref de Vue (que se desenvuelve automáticamente).

Angular sigue siendo el Swiss Army knife: opinionated, completo, gran curva de aprendizaje, pero propone estructura en equipos grandes donde la consistencia pesa más que la flexibilidad.

Svelte: compilación, no vDOM ni runtime reactivity (¿en realidad?)

La 4.ª y 5.ª generación de Svelte transforma componentes .svelte en código imperativo que actualiza el DOM directamente. Sin virtual DOM, sin diffing.

<script>
  let count = 0;
  function increment() { count += 1; }
</script>

<div>{count}</div>
<button on:click={increment}>Click me</button>
<script>
  let count = 0;
  let doubleCount = 0;
  $: doubleCount = count * 2;     // se recalcula cuando count cambia
</script>

<div>{doubleCount}</div>

El problema de $: es que es estático: el compilador determina dependencias al parsear. Si extraes una función, las dependencias se pierden. Svelte 5 con runes resuelve esto:

<script>
  let { width, height } = $props();
  const area = $derived(width * height);

  $effect(() => {
    console.log(area);        // dependencias detectadas en tiempo de ejecución
  });
</script>
<script>
- let count = 0;
+ let count = $state(0);
  function increment() { count += 1; }
</script>
Detrás de las runes

Svelte 5 está propulsado por signals. No los ves como $state lo expone, pero internamente son lo mismo que Solid y Angular Signals. La frase del capítulo es precisa: “Knockout estaba en lo correcto en 2010”.

Solid: reactividad fina, JSX familiar

Solid (Ryan Carniato) se parece a React en sintaxis (JSX) pero reinvente el modelo: el componente no se reejecuta, solo las porciones reactivas se actualizan.

import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);    // Signal: read/write split
  return (
    <>
      <div>{count()}</div>                       // count() registra suscripción
      <button onClick={() => setCount(count() + 1)}>+</button>
    </>
  );
}
La diferencia con React

En React, setCount reejecuta Counter entero. En Solid, setCount notifica a los subscribers del signal count. Counter solo se ejecuta una vez. Es la diferencia entre coarse-grained y fine-grained reactivity.

Read/write segregation

const [count, setCount] = createSignal(0);
count();                  // read
setCount(1);              // write
setCount(prev => prev + 1);   // write derived

LaSignal devuelve [read, write] separados. Si pasas count a un hijo, no tienes forma de mutarlo desde fuera (la setter no la recibes). Es un api shape deliberado: la inmutabilidad-from-the-outside es estructural.

Qwik: “the O(1) framework”

Qwik ataca el problema del tamaño del bundle: en vez de enviar el JS de la app, envía solo un loader de ~1 kB y carga cada componente en el momento exacto en que se necesita.

   Tamaño de bundle al cargar la página
   ──────────────────────────────────────
   React/Vue/Svelte:  variable (puede ser MB)
   Qwik:              O(1) ≈ 1 kB

Resumability, no hidratación

Recordemos del Cap. 7: hidratación significa renderizar otra vez el árbol en cliente para enganchar listeners. Qwik lo evita con resumability: serializa en el HTML qué listener va dónde y reanuda la interactividad sin re-renderizar.

   Hidratación                  Resumability
   ───────────                  ─────────────
   Server render                 Server render
       ↓                              ↓
   HTML → Client                 HTML + meta (qué listener en qué nodo)
       ↓                              ↓
   Hydrate (rerender)            Resume (pick up donde el server paró)
       ↓                              ↓
   Interactive                   Interactive
Compatibilidad con React

Qwik tiene qwikify para interoperar con componentes React. No se recomienda para todo, pero permite migrar piezas.

DX: JSX casi idéntico a React

import { component$, useSignal, $ } from "@builder.io/qwik";

export const Counter = component$(() => {
  const count = useSignal(0);     // signal
  return (
    <>
      <div>{count.value}</div>
      <button onClick$={() => count.value++}>+</button>
    </>
  );
});

onClick$ y component$ terminan en $ para indicar al compilador que esa parte es un límite lazy: Qwik genera referencias HTML para que se carguen bajo demanda cuando el usuario interactúe.

React no es reactivo en sentido tradicional

Volvamos al <Counter> que vimos en cada framework:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

Cuando llamas setCount, React reinvoca Counter entero. Esa es la fórmula clásica:

   v = f(s)

La vista es función del estado. Pero esa función no se llama sola: la invoca React cuando algo cambia. En contraste:

function Counter() {
  const [count, setCount] = createSignal(0);
  return (
    <div>
      <p>{count()}</p>
      <button onClick={() => setCount(count() + 1)}>+</button>
    </div>
  );
}

Counter se ejecuta una vez. count() registra una suscripción en el JSX. setCount notifica y React — perdón, Solid — actualiza solo el <p> que escuchaba.

Coarse-grained vs fine-grained

ModeloQuién actualizaCuándo
Coarse-grained (React)El componente entero se reejecuta y el reconciler decide qué diffs.En cada cambio de estado/props del componente o de un ancestro.
Fine-grained (Solid, Vue 3, Svelte 5, Angular Signals, Qwik)Solo las celdas reactivas que leyeron el signal.En cada setSignal/write de un valor del cual alguien está suscrito.
Implicaciones

Fine-grained es casi siempre más performante y menos trabajo para el desarrollador (no necesita memo ni pensar en dependencias). Pero coarse-grained da predictibilidad: el render siempre ve el mismo snapshot del estado, lo que facilita useSyncExternalStore y debug.

React Forget: el camino del compilador

El equipo de React ha dicho que no adoptarán signals. Su respuesta: un compilador (Forget) que detecta qué valores no cambian a lo largo del ciclo de vida y los memoiza automáticamente.

const MemoizedExpensive = memo(ComponentWithExpensiveChildren);

// ... código de usuario obligado a acordarse ...
function Counter() {
  const [count, setCount] = useState(0);

  function increment() { setCount(count + 1); }

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+</button>
      <ComponentWithExpensiveChildren />     {/* Forget meterá memo aquí */}
    </div>
  );
}

Forget se aprovecha de las reglas de React:

  1. Los componentes son funciones puras.
  2. Algunos hooks y event handlers no son puros.
  3. Las funciones puras no pueden mutar bindings externos.
  4. Las funciones puras solo leen props o state.

Con esas reglas, Forget puede predecir qué valores no cambian y emitir useMemo/memo por nosotros. Resultado: misma perf-or-smance que signals sin que el desarrollador tenga que pensar en memo.

Decisión del equipo

El equipo ve signals como un implementation detail. La postura filosófica es: “declarativamente describe tu UI, deja que React se ocupe del cómo” — y Forget es la encarnación tecnológica de esa promesa.

Forget vs signals — la jugada

DimensiónSignalsForget
API visiblecreateSignal, ref, $state que el desarrollador usaCero: el compilador lo deduce
GranularidadFina: solo cambia lo suscritoIgual de fina una vez compilada
Live outside component treeSí (puede haber signals a nivel global)No: vive donde React los monta
VerbosidadAPIs explícitas en códigoCero APIs nuevas
Coste mentalAprender cuándo subscribirseNinguno: la memoria decide
Trade-off futuroCambiar de framework te obliga a reentenderCompilador se mantiene entre versiones
Comparativa justa, distintos compromisos

Ambos llevan a software rápido y pequeño. Signals te hacen explícito lo que Forget deduce. En cinco años miraremos atrás y veremos que las dos rutas convergen: el código que Forget emite es una capa de signals, solo que la escribe el compilador.

Resumen del capítulo

La lección final del autor

“No hay un one-size-fits-all. Cada framework es una opinión distinta sobre quién decide qué en la UI. Entender esas opiniones hace mejores ingenieros, incluso si terminas eligiendo React.”

Próximos pasos

El cierre: en 12-conclusion se resume el viaje entero y qué mirar para no quedarse atrás.