Un store reactivo en vanilla JS

lectura acompañada

Pega este protocolo en tu asistente y avísale al llegar a cada pausa: revisará tu comprensión ahí y al terminar el artículo.

ver el protocolo
# Lectura acompañada de un artículo de Fronteando

Vas a acompañar a una persona mientras lee un artículo técnico del blog
Fronteando. Tu rol no es dar la clase: la persona lee el artículo por su
cuenta y vuelve a esta conversación en las pausas marcadas para
contrastar lo que entendió.

## Reglas

- Espera a que la persona anuncie una pausa («pausa 1», «pausa 2»…) o
  termine el artículo antes de verificar nada: no te adelantes a
  secciones que todavía no alcanzó.
- En cada pausa aplica la verificación indicada para esa pausa: haz
  una o dos preguntas que confirmen la comprensión, ofrece otro
  ejemplo si lo pide o deja seguir.
- Si una respuesta muestra un malentendido, explícalo con tus palabras
  apoyándote en el tema del artículo, sin adelantar lo que sigue.
- Responde en español, claro y breve: dos o tres frases por turno salvo
  que te pidan profundizar.
- Fuera de las pausas la conversación es libre: dudas sueltas, ejemplos
  alternos, profundización sobre lo ya leído.
- Si puedes acceder a la URL del artículo, léelo para apoyar tus
  respuestas; si no, no finjas haberlo leído: trabaja con lo que
  sepas del artículo y con lo que la persona te comparta.
- Arranca dándole a la persona las indicaciones del acompañamiento:
  cuéntale que vas a acompañar su lectura y que te avise al llegar a
  cada pausa o cuando termine el artículo, para que le ayudes a
  contrastar su comprensión. No expliques el artículo por adelantado.

Si después de estas reglas viene la URL del artículo o más información
sobre él, úsala para ubicar de qué texto se habla. Si no viene nada,
pide a la persona el enlace antes de empezar.

## Ficha del artículo

Título: Un store reactivo en vanilla JS
URL: https://www.fronteando.dev/articulos/un-store-reactivo-en-vanilla-js/
Tema: construir un store reactivo desde cero en JavaScript sin librerías: un signal que notifica, un effect que se registra solo y un Proxy que lo generaliza por propiedad
Nivel: intermedio
Prerequisitos: JavaScript con soltura (funciones, closures), getters y setters de objetos

Pausas:
1. Explica con tus palabras qué le cuesta al consumidor suscribirse a mano y qué errores trae ese cableado.
2. Explica quién toma nota de quién: por qué el suscriptor es correr y no fn.
3. Explica por qué un efecto que solo lee estado.nombre no se reejecuta cuando cambia estado.nivel.

Cambia una variable y la pantalla se actualiza sola. Si has usado Vue, Solid, Preact o cualquier framework reciente, ese gesto te es familiar: escribes count.value++ y el DOM obedece. Parece magia. No lo es.

En este artículo vamos a construir ese mecanismo desde cero, en JavaScript sin librerías: unas cincuenta líneas que hacen lo esencial de un store reactivo. Al final, ref() y signal() dejarán de ser conjuros para convertirse en algo que ya sabes escribir (porque mientras algo parezca magia, solo queda memorizar la API).

El resultado primero

Antes de desmontar la magia conviene verla funcionar. Este es el programa completo que vamos a explicar:

const count = signal(0);

effect(() => {
  document.title = `count: ${count.value}`;
});

count.value = 1; // el título del documento ya dice «count: 1»

Ninguna línea avisa de nada a nadie: asignar count.value basta para que el título se reescriba. Tres piezas lo hacen posible: signal, effect y un truco pequeño que las conecta. Las construiremos una a una.

Estado que notifica

La primera pieza es la más vieja del mundo: una variable que avisa a quien se suscriba cuando cambia. Un getter devuelve el valor y un setter, además de guardarlo, avisa a cada suscriptor:

function signal(valorInicial) {
  let valor = valorInicial;
  const suscriptores = new Set();

  return {
    suscribir(fn) {
      suscriptores.add(fn);
    },
    get value() {
      return valor;
    },
    set value(nuevo) {
      valor = nuevo;
      suscriptores.forEach((fn) => fn());
    },
  };
}

Y se usa así:

const count = signal(0);

count.suscribir(() => {
  document.title = `count: ${count.value}`;
});

count.value = 1;
count.value = 2;

Funciona, pero el defecto salta a la vista: cada consumidor tiene que acordarse de suscribirse y de repetir dentro lo que lee. Con un componente real (varias propiedades, varios consumidores) el cableado manual se convierte en la fuente de los errores: el efecto que nadie suscribió, el que quedó suscrito al dato equivocado.

pausa

Explica con tus palabras qué le cuesta al consumidor suscribirse a mano y qué errores trae ese cableado.

El efecto que se registra solo

El paso que convierte esto en reactividad es un cambio de dirección: en lugar de que el consumidor se suscriba, es el signal quien toma nota de quién lo está leyendo.

La pieza es una variable global que apunta al efecto en ejecución:

let efectoActivo = null;

function effect(fn) {
  function correr() {
    efectoActivo = correr;
    fn();
    efectoActivo = null;
  }
  correr();
}

effect envuelve fn en correr: al ejecutarse, correr se declara efecto activo, ejecuta fn y limpia. El suscriptor real es correr, no fn: así cada reejecución vuelve a declararse efecto activo y a registrar lo que lea. Como JavaScript corre en un solo hilo, mientras correr se ejecuta ningún otro efecto puede estar en marcha: efectoActivo es, sin ambigüedad, «el efecto que está corriendo ahora».

Ahora el getter del signal aprovecha ese dato:

get value() {
  if (efectoActivo) suscriptores.add(efectoActivo);
  return valor;
}

Leer value dentro de un effect registra la dependencia sin que nadie lo pida: si hay un efecto en ejecución, queda suscrito. Con esa pieza suscribir desaparece y el programa del gancho ya es real:

const count = signal(0);

effect(() => {
  document.title = `count: ${count.value}`;
}); // al ejecutarse, queda suscrito a count

count.value = 1; // reejecuta el efecto
count.value = 2; // y otra vez

El grafo de dependencias se construye solo: cada efecto queda anotado en los signals que lee cada vez que corre. Ese matiz («cada vez que corre») importa; volveremos a él en los límites.

pausa

Explica quién toma nota de quién: por qué el suscriptor es correr y no fn.

De un valor a un objeto: la store

Un signal envuelve un valor. La generalización natural es un objeto donde cada propiedad se comporta igual: leer registra, escribir notifica. Para interceptar lecturas y escrituras de propiedades arbitrarias existe Proxy:

function store(objeto) {
  const suscriptoresPorProp = new Map();
  const subsDe = (prop) => {
    if (!suscriptoresPorProp.has(prop)) {
      suscriptoresPorProp.set(prop, new Set());
    }
    return suscriptoresPorProp.get(prop);
  };

  return new Proxy(objeto, {
    get(obj, prop) {
      if (efectoActivo) subsDe(prop).add(efectoActivo);
      return obj[prop];
    },
    set(obj, prop, nuevo) {
      obj[prop] = nuevo;
      subsDe(prop).forEach((fn) => fn());
      return true;
    },
  });
}

La mejora sobre signal es que la suscripción es por propiedad: cada Set pertenece a una clave del objeto, así que un efecto solo se reejecuta cuando cambia algo que de verdad leyó:

const estado = store({ nombre: "ana", nivel: 1 });

effect(() => {
  document.title = `${estado.nombre} · nivel ${estado.nivel}`;
});

estado.nivel = 2; // reejecuta el efecto
estado.nombre = "lu"; // y otra vez

Y la prueba de que el grafo es fino: un efecto que solo lee estado.nombre no corre cuando cambia estado.nivel. La reactividad ya no es por variable, es por propiedad.

pausa

Explica por qué un efecto que solo lee estado.nombre no se reejecuta cuando cambia estado.nivel.

Lo que falta para ser un framework

Lo construido es el corazón de la reactividad, no el framework completo. Conviene nombrar lo que quedó fuera, porque cada omisión es una pregunta que los frameworks tuvieron que responder:

  • Los efectos no se limpian. Cada ejecución anota lo que el efecto leyó, pero nada borra lo anotado antes: si un efecto lee una propiedad en una ejecución y deja de leerla en la siguiente, la suscripción vieja permanece. Los frameworks descartan las dependencias viejas en cada ejecución y ofrecen onCleanup o dispose.
  • No hay batching. Cada escritura dispara los efectos al momento: diez set seguidos son diez reejecuciones. Los frameworks agrupan las notificaciones en un solo pase.
  • El render no existe. Nuestro efecto reescribe document.title entero; escalar eso al DOM es justamente lo que los frameworks resuelven (diffing en los frameworks de virtual DOM, actualización quirúrgica compilada en Solid o Svelte).
  • Un solo efecto a la vez. efectoActivo es una variable, así que los efectos no pueden anidarse; la versión de verdad mantiene una pila.

Y sin embargo, la sustancia ya está (en unas cincuenta líneas): leer registra, escribir notifica y la lista de dependencias la lleva la propia pieza de estado.

El punto

ref() en Vue, createSignal() en Solid, signal() en Preact: detrás de cada una está esta misma idea (un contenedor que toma nota de quién lee y avisa cuando cambia) vestida de API y rodeada de lo que sí cuesta trabajo: render eficiente, batching, cleanup.

La próxima vez que count.value++ mueva tu pantalla, ya sabes qué acaba de ocurrir: un getter anotó a tu efecto y un setter lo volvió a llamar.

Sincronizar el estado con la vista ya no cuesta nada. La pregunta que sí queda abierta es cómo estructurar todo lo demás (quién escucha los eventos, quién decide, qué se guarda dónde). De esa pregunta va la siguiente serie.