wandres.dev
CREATESTORE · reactividad anidada

createStore: un proxy reactivo para árboles

createStore envuelve un objeto anidado en un proxy de solo lectura donde cada propiedad se comporta como su propio signal creado de forma perezosa. Leer una ruta como estado.usuario.nombre dentro de un scope reactivo suscribe solo a esa hoja, y escribir por ruta con setEstado notifica únicamente a quien la lee. Es la primitiva de Solid para estado con forma.

⏱ 15 min

Un signal modela un átomo: un valor que reemplazas entero. Pero el estado real de una aplicación rara vez es atómico; es un árbol —un usuario con perfil y preferencias, un documento con secciones, un carrito con líneas— donde distintas partes cambian y se leen de forma independiente. Meter ese árbol en un único signal colapsa toda su granularidad en una sola arista. createStore es la respuesta de Solid: envuelve la estructura en un proxy donde cada propiedad, por honda que esté, se comporta como su propio signal. Leer una hoja te suscribe solo a ella; escribirla despierta solo a quien la mira.

🎯 Al terminar esta lección sabrás
  • Entender createStore como un proxy que rastrea por propiedad, no como un objeto plano.
  • Ver cómo cada hoja se convierte en un signal creado de forma perezosa al leerla.
  • Leer por ruta (estado.usuario.nombre) y comprender que suscribe solo a esa hoja.
  • Escribir por ruta con setEstado sin reconstruir los niveles intermedios.

El proxy que envuelve la estructura

createStore recibe un objeto inicial y devuelve una tupla [estado, setEstado], idéntica en forma a la de createSignal. Pero el primer elemento no es tu objeto: es un proxy de solo lectura que lo recubre. Cada vez que accedes a una propiedad cuyo valor es a su vez un objeto o un array, el proxy devuelve otro proxy, de manera perezosa y recursiva. El árbol completo queda cubierto por una malla de proxies que interceptan cada lectura y cada escritura.

import { createStore } from "solid-js/store";

const [estado, setEstado] = createStore({
  usuario: { nombre: "Ada", edad: 36 },
  preferencias: { tema: "oscuro" },
});

estado.usuario.nombre; // "Ada" — atraviesa dos proxies hasta la hoja

Ese recubrimiento no es gratuito conceptualmente, pero sí barato en la práctica: los proxies internos no se crean todos por adelantado, sino la primera vez que alguien accede a la rama que los contiene. Un store con mil ramas que nadie mira no instala mil proxies; instala solo los del camino que de verdad recorres.

La primera consecuencia práctica es que el proxy es de solo lectura. No puedes asignar estado.usuario.nombre = "Grace" directamente: en desarrollo, Solid te avisa de que estás mutando un store por fuera. Toda escritura pasa por setEstado, y esa asimetría deliberada —lectura directa, escritura por función— es la que le permite al store saber exactamente qué cambió y a quién avisar.

Cada hoja es un signal perezoso

La idea central es esta: dentro del proxy, cada propiedad se comporta como su propio signal. No hay un único signal para todo el objeto, sino un enjambre de signals, uno por hoja, todos creados de forma perezosa. La primera vez que lees estado.usuario.nombre dentro de un scope reactivo, el store instala el signal de esa hoja y te suscribe a él. Las hojas que nadie lee nunca llegan a costar un signal.

import { createEffect } from "solid-js";

createEffect(() => {
  console.log(estado.usuario.nombre); // suscribe SOLO a usuario.nombre
});

setEstado("usuario", "edad", 37);       // el efecto no se entera: leyó nombre, no edad
setEstado("usuario", "nombre", "Grace"); // el efecto sí corre: cambió su hoja

Este es el corazón de la reactividad de grano fino sobre estructuras. En un signal que guardara el mismo objeto, cambiar edad reemplazaría el objeto entero y notificaría a todo el que leyera cualquier campo. En el store, edad y nombre son dependencias distintas: tocar una deja la otra en completo silencio.

Cada hoja usa además la igualdad por defecto de Solid, ===. Reescribir una hoja con el valor que ya tenía es un no-op silencioso: el store compara, ve que no cambió y no notifica a nadie. Esto te ahorra propagaciones inútiles sin que hagas nada especial, y de paso explica un caso desconcertante —mutar por dentro un objeto que ya vive en una hoja y reasignarlo tampoco notifica, porque le entregas la misma referencia.

setEstado("usuario", "nombre", "Grace");
setEstado("usuario", "nombre", "Grace"); // no-op: la hoja no cambió, nadie despierta
flowchart TD
R[proxy raiz estado] --> U[proxy usuario]
R --> P[proxy preferencias]
U --> N[hoja nombre]
U --> E[hoja edad]
P --> T[hoja tema]
L[efecto que lee usuario.nombre] --> N
style N fill:#a6e3a1,color:#11111b
style E fill:#45475a,color:#cdd6f4
style L fill:#89b4fa,color:#11111b

Conviene subrayar el matiz de dentro de un scope reactivo. Leer estado.usuario.nombre en el cuerpo suelto de una función, fuera de todo efecto, memo o JSX, devuelve el valor pero no suscribe a nada, porque no hay ningún oyente que registrar. La suscripción solo ocurre cuando la lectura sucede bajo un cómputo que rastrea. Es la misma regla que gobierna a los signals; el store no la cambia, solo la extiende a cada hoja del árbol.

Escribir por ruta con setEstado

setEstado acepta una ruta —una secuencia de claves— y, como último argumento, el nuevo valor o un actualizador. En lugar de reconstruir cada nivel con spread, nombras el camino a la hoja y Solid escribe solo ahí, reconstruyendo internamente el trayecto y notificando a los lectores de esa rama.

// Valor directo en la hoja
setEstado("usuario", "nombre", "Grace");

// Actualizador funcional: recibe el valor previo de esa hoja
setEstado("usuario", "edad", (n) => n + 1);

// Objeto en una rama: merge SUPERFICIAL de esas claves
setEstado("usuario", { nombre: "Grace", edad: 37 });

// Objeto en la raíz: merge superficial de las claves de primer nivel
setEstado({ preferencias: { tema: "claro" } });

La ruta no está limitada a claves literales: cualquier segmento puede ser una variable calculada en runtime —setEstado(rama, campo, v)— de modo que una función genérica de actualización puede escribir en cualquier parte del árbol sin conocerla de antemano. Es la misma potencia que hace del store el recipiente natural para estado con claves dinámicas, un caso que veremos con detalle en las lecciones siguientes.

Hay dos comportamientos que no debes confundir. Cuando el último argumento es un valor primitivo o una función, se asigna o se deriva esa hoja concreta. Cuando es un objeto, Solid hace un merge superficial de sus claves en el nivel indicado: setEstado("usuario", { edad: 37 }) cambia edad y deja nombre intacto, en lugar de reemplazar el objeto usuario entero. Esa diferencia —merge de objeto frente a reemplazo de hoja— es de las primeras cosas que hay que interiorizar del store, porque de ella depende que no borres campos sin querer.

📖

Leer por ruta

estado.a.b.c atraviesa proxies hasta la hoja y, bajo un scope reactivo, suscribe solo a ella. Cuanto más honda la ruta, más fino el grano de la dependencia.

✍️

Escribir por ruta

setEstado("a", "b", "c", v) nombra el camino y asigna la hoja. Si el último argumento es un objeto, fusiona sus claves de forma superficial en ese nivel.

Desenvolver el store: del proxy al objeto crudo

A veces necesitas el objeto plano que vive bajo el proxy: para serializarlo, para entregárselo a una librería ajena a Solid, o para depurar sin la capa reactiva de por medio. Esa es la función de unwrap, que te devuelve la estructura original —sin proxies, sin suscribir a nada.

import { unwrap } from "solid-js/store";

const plano = unwrap(estado);   // objeto crudo, sin proxies ni reactividad
JSON.stringify(unwrap(estado)); // seguro para volcar el estado tal cual

Dos cautelas acompañan a unwrap. La primera: el objeto que devuelve es el estado real, no una copia, así que mutarlo por fuera corrompe el store a espaldas del sistema —léelo, no lo escribas. La segunda: unwrap no es reactivo; leerlo dentro de un efecto no suscribe a las hojas, porque justamente esquiva el proxy que las rastrea. Es la herramienta del borde de tu aplicación, donde el dato sale de Solid, no del interior del grafo reactivo.

ℹ️
El merge del store es superficial, no profundo

Pasar un objeto a setEstado fusiona solo el primer nivel de sus claves. setEstado("usuario", { perfil: { bio: "x" } }) reemplaza por completo la rama perfil, no fusiona dentro de ella. Para tocar un campo hondo sin reemplazar su contenedor, extiende la ruta hasta él: setEstado("usuario", "perfil", "bio", "x"). La regla mental es simple: el merge llega hasta donde llega la ruta, y a partir de ahí asigna lo que le des.

El store es reactividad de grano fino con forma de objeto

El salto conceptual que hay que dar es dejar de ver createStore como una comodidad para no escribir spread anidado, y empezar a verlo como lo que realmente es: la extensión de la reactividad de grano fino de Solid al dominio de las estructuras. Un signal tiene un único nodo reactivo; un store tiene uno por hoja, creado perezosamente en el instante en que alguien lo lee. Esa diferencia lo cambia todo. Leer estado.a.b.c no es acceder a un campo de un objeto: es suscribirse a una dependencia precisa, tan fina como si hubieras declarado un signal exclusivo para c. Escribir setEstado("a", "b", "c", v) no es reconstruir el árbol: es notificar exactamente a los oyentes de esa hoja y a nadie más. Por debajo, el proxy hace a la vez tres cosas que a mano serían tediosas y frágiles: crea el signal de cada hoja solo cuando hace falta, reconstruye el camino inmutable de la raíz a la hoja tocada compartiendo estructuralmente todo lo demás, y conserva la identidad de las ramas que no cambiaron para que ni los efectos ni un <For> que dependan de ellas se disparen. El resultado es que recuperas, sobre un árbol de datos arbitrariamente profundo, la misma promesa que te dio el signal sobre un valor atómico: el cambio se propaga solo a quien depende de la parte que cambió. No es azúcar sintáctico sobre objetos inmutables; es el mismo motor reactivo que ya conoces, con la granularidad devuelta a cada propiedad del árbol.

⚔️ Suscríbete a una hoja, no al árbol
  1. Crea un store con un usuario anidado y lee estado.usuario.nombre dentro de un createEffect; cambia estado.usuario.edad con setEstado y confirma que el efecto no corre.
  2. Cambia ahora estado.usuario.nombre y verifica que el mismo efecto sí se dispara.
  3. Intenta asignar estado.usuario.nombre = "X" de forma directa y observa la advertencia de Solid en desarrollo.
  4. Usa setEstado("usuario", { edad: 40 }) y comprueba que nombre sigue intacto; luego reemplaza una rama anidada pasando un objeto y confirma que el merge es superficial.
  5. Explica en una frase por qué leer una hoja fuera de un scope reactivo no suscribe a nada.