wandres.dev
REDUX FUERA DE REACT · agnóstico de vista

Nanostores y el multi-framework: un store entre islas

La arquitectura de islas rompe el supuesto que sostenía a todos los gestores de estado anteriores: que la aplicación es un único árbol de componentes de un único framework. En Astro conviven islas de React, Vue y Svelte que no comparten árbol, ni contexto, ni proveedor, y necesitan sin embargo compartir estado. Nanostores responde con átomos de unos cientos de bytes y adaptadores por framework, y esta lección monta el caso completo, explica por qué el módulo ES es el nuevo contenedor de la verdad, y examina las fronteras nuevas que introducen la hidratación y el renderizado en servidor.

⏱ 17 min

Todos los gestores de estado que hemos estudiado comparten un supuesto tan básico que nunca lo enunciamos: que la aplicación es un único árbol de componentes de un único framework, con una raíz donde colocar un proveedor. La arquitectura de islas rompe ese supuesto de raíz. En una página de Astro puede haber una isla escrita en React junto a otra en Vue y otra en Svelte, cada una hidratada por separado, sin árbol común, sin contexto compartido y sin ningún lugar donde envolver a todas. Y sin embargo el carrito que muestra la cabecera tiene que enterarse cuando el botón de la ficha de producto añade un artículo. Nanostores es la respuesta más limpia a ese problema, y su respuesta consiste, una vez más, en volver al núcleo agnóstico de la lección uno.

🎯 Al terminar esta lección sabrás
  • Entender por qué el proveedor y el contexto dejan de servir cuando no hay un árbol único.
  • Usar el módulo ES como contenedor de la verdad compartida entre islas independientes.
  • Montar un store de Nanostores leído desde islas de frameworks distintos en la misma página.
  • Reconocer las fronteras nuevas que imponen la hidratación y el renderizado en servidor.

El problema que las islas crean

Una isla es un fragmento interactivo que se hidrata solo, rodeado de HTML estático. Astro las declara con directivas como client:load o client:visible, y cada una arranca su propio runtime de forma aislada: dos islas de React en la misma página no comparten árbol ni raíz de render, y por tanto no comparten contexto. Un Provider de react-redux colocado dentro de una isla es invisible para la de al lado, aunque ambas estén escritas en React. Si además una está en React y otra en Vue, ni siquiera comparten sistema de reactividad.

🏝️

Sin árbol común

Cada isla se monta por separado. No hay raíz donde envolver un proveedor que alcance a todas.

🧬

Sin runtime común

React, Vue y Svelte notifican cambios de formas incompatibles. Ninguna capa de enlace sirve para las tres.

📦

Con módulos comunes

Lo único que todas comparten es el grafo de módulos del navegador. Ahí puede vivir la verdad.

⚖️

Con presupuesto estricto

Las islas existen para enviar poco JavaScript. Un store de 15KB por isla contradice el motivo de usarlas.

Las alternativas que uno intenta antes de aceptar el diagnóstico fallan todas por la misma razón. Pasar datos por props solo funciona hacia abajo dentro de una isla y no cruza de una a otra. Los eventos personalizados sobre el objeto window funcionan, pero no tienen estado: quien llega tarde a la fiesta no puede preguntar cuál es el valor actual, y acabas reinventando un store peor. Guardar en el almacenamiento local sincroniza pestañas pero no notifica dentro de la misma página con la inmediatez que la vista necesita. Todas fracasan por carecer de alguna de las garantías del patrón, y su fracaso es una demostración indirecta de que las cuatro garantías son necesarias, no decorativas.

Esa lista deja el diagnóstico servido. Lo único que todas las islas de una página comparten es el grafo de módulos: si dos de ellas importan el mismo archivo, obtienen la misma instancia, porque el navegador evalúa cada módulo una sola vez. El contenedor de la verdad, cuando no hay árbol, es el propio sistema de módulos. Es exactamente el mecanismo que usamos en la lección uno para el contador de vanilla, y aquí deja de ser una curiosidad didáctica para volverse la única opción viable.

Nanostores: el núcleo mínimo y sus adaptadores

Conviene aclarar que nada de esto es exclusivo de Nanostores. Un store de Redux importado desde un módulo compartido resolvería el problema igual de bien en cuanto le escribieras un adaptador para cada framework, y ese ejercicio aparece al final del reto porque hacerlo una vez enseña más que cualquier explicación. La razón para preferir la librería específica es de tamaño y de mantenimiento: los adaptadores ya existen, están probados contra las particularidades de cada runtime y pesan casi nada. La arquitectura sería la misma con cualquiera de las dos.

Nanostores lleva la idea de la lección uno a su conclusión extrema: un store es un valor observable de unos cientos de bytes, sin dependencias, sin framework, y los enlaces se distribuyen como paquetes separados —uno para React, uno para Vue, uno para Svelte— que cada isla importa según lo que sea. La librería reconoce explícitamente la separación entre patrón y capa de enlace que este nivel viene demostrando.

// stores/carrito.ts — un solo modulo, importado por islas de frameworks distintos
import { atom, computed } from 'nanostores'

export const articulos = atom<Articulo[]>([])

// estado derivado: el equivalente de createSelector, memoizado y automatico
export const total = computed(articulos, (lista) =>
  lista.reduce((suma, a) => suma + a.precio * a.cantidad, 0),
)

// la frontera de escritura: funciones exportadas, no set suelto por ahi
export function anadir(articulo: Articulo) {
  articulos.set([...articulos.get(), articulo])
}

La familia de primitivas es deliberadamente corta y cada una responde a una necesidad que ya conoces del track: atom para un valor suelto, map para un objeto cuyas claves se observan por separado y así evitar repintados innecesarios, computed para el estado derivado del Nivel 5, y variantes persistentes que sincronizan el valor con el almacenamiento del navegador y entre pestañas. No hay middleware, no hay historial de acciones y no hay viaje en el tiempo: la librería elige explícitamente pagar en trazabilidad lo que gana en tamaño, y lo hace porque el contexto donde vive —islas con presupuesto ajustado de JavaScript— hace que esa sea la elección sensata.

Reconoce las piezas: get es getState, set es la escritura controlada, subscribe sigue existiendo por debajo y computed es el selector memoizado. Lo único que falta respecto a Redux es el objeto de acción con tipo, y su ausencia se paga en trazabilidad igual que en Pinia. Lo que se gana a cambio es un tamaño que cabe en el presupuesto de una isla y una independencia total del framework que la hospeda.

flowchart TD
M[modulo stores carrito ts] --> A[atom articulos y computed total]
A --> R[isla en react con useStore]
A --> V[isla en vue con useStore]
A --> S[isla en svelte con dolar store]
R -->|anadir| A
V -->|anadir| A
H[html estatico de astro] --- R
H --- V
H --- S
style M fill:#f9e2af,color:#11111b
style A fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b

Tres islas, tres frameworks, un estado

El caso completo se ve mejor en la página que las compone. Astro no sabe nada del store ni le importa: se limita a montar tres islas independientes que resultan importar el mismo módulo.

Ese desinterés del framework anfitrión es exactamente la propiedad que buscamos. Astro no ofrece —ni necesita ofrecer— una solución de estado propia, porque el problema no es suyo: él compone HTML y decide cuándo hidratar cada fragmento. La coordinación entre fragmentos ocurre una capa por debajo, en el grafo de módulos que el navegador ya gestiona, y por eso funciona igual con cualquier combinación de tecnologías presente o futura.

// una isla de React lee y escribe el mismo atomo
import { useStore } from '@nanostores/react'
import { articulos, total, anadir } from '../stores/carrito'

export function Cabecera() {
  const suma = useStore(total)
  const lista = useStore(articulos)
  return <span>{lista.length} articulos por {suma} euros</span>
}
// una isla de Vue, en otro archivo y con otro runtime, ve exactamente lo mismo
import { useStore } from '@nanostores/vue'
import { articulos, anadir } from '../stores/carrito'

export default {
  setup() {
    const lista = useStore(articulos)
    return { lista, anadir }
  },
}

Un detalle de disciplina que el ejemplo esconde y conviene explicitar: el módulo exporta el átomo y la función anadir, pero lo correcto es que las islas escriban solo a través de la función. Exportar el átomo para leerlo es necesario; permitir que cualquier isla llame a su set desde donde le convenga disuelve la frontera de escritura y devuelve el sistema al estado del que Flux nos sacó. La regla es la misma de siempre, expresada aquí con módulos en lugar de con acciones: la lectura es abierta, la escritura pasa por un conjunto pequeño de funciones con nombre.

Las dos islas comparten estado sin conocerse. Cuando la de Vue llama a anadir, el átomo notifica a sus suscriptores, el adaptador de React traduce esa notificación a un render y la cabecera se actualiza. Ni una línea de coordinación entre frameworks: el store no sabe quién lo escucha y cada adaptador solo sabe traducir una suscripción a su propio modelo de reactividad. Es la arquitectura de la lección uno, llevada al extremo por necesidad.

Este montaje deja además una consecuencia estratégica que las migraciones agradecen: si la verdad vive en un módulo agnóstico y cada isla solo aporta su capa de enlace, puedes reescribir una isla de React a Svelte sin tocar el estado, o introducir un framework nuevo en una página sin migrar nada de lo existente. La arquitectura de islas convierte la elección de framework en una decisión local y reversible, y solo lo consigue porque el estado compartido se negó a pertenecer a ninguno de ellos.

Vale la pena medir el coste de esta solución con números, porque explica por qué no se usa aquí ninguna de las herramientas de los niveles anteriores. Cada isla que importa el store paga su parte del paquete, y en una página con cuatro islas el peso del gestor de estado se cuenta una vez por el módulo compartido más un adaptador diminuto por framework. Con un store de quince kilobytes, la aritmética se vuelve absurda frente al motivo mismo de usar islas, que es enviar poco JavaScript. La restricción de tamaño no es un capricho de la librería: es el requisito que define el problema.

💡
Regla práctica: el estado compartido entre islas es el mínimo indispensable

Cuando dos islas comparten estado, están acopladas, y ese acoplamiento cruza la frontera que la arquitectura de islas dibujó a propósito. La disciplina que funciona es tratar el store compartido como una excepción justificada y no como el sitio por defecto donde poner cosas: el carrito, la sesión y el tema lo merecen; el estado interno de un formulario, un desplegable abierto o el paso de un asistente no, y deben quedarse dentro de su isla con las herramientas locales del framework que la escribe. Cada dato que subes al módulo compartido convierte dos islas independientes en un sistema que hay que razonar entero.

⚠️
La frontera nueva: el servidor y la hidratación

Las islas introducen dos problemas que ningún gestor de estado clásico tenía. El primero es que en el servidor no hay usuarios distintos: un módulo evaluado una vez es global al proceso, así que escribir estado de sesión en un store de módulo durante el renderizado en servidor filtra datos de un visitante a otro. La regla es que el estado compartido entre islas es estado de navegador, y en el servidor solo debe existir como valor inicial pasado por props. El segundo es el desfase de hidratación: si una isla es client:visible y otra client:load, la segunda puede escribir el store antes de que la primera exista, y esa primera debe leer el valor actual al montarse en vez de asumir el inicial. Los adaptadores lo hacen por ti, pero cualquier suscripción manual que escribas tiene que leer una vez al arrancar, exactamente como el render inicial del contador de la lección uno.

Cuando desaparece el árbol, queda el patrón

La arquitectura de islas es el mejor banco de pruebas que ha tenido este track porque retira, una a una, todas las comodidades que dábamos por supuestas —una raíz común, un contexto, un proveedor, un único sistema de reactividad, incluso un único framework— y observa qué sigue en pie cuando ya no queda nada de eso. Lo que sigue en pie es exactamente el patrón: una fuente de verdad con dueño claro, una frontera de escritura estrecha por la que pasan todos los cambios, estado derivado que se recalcula solo, y suscriptores que se enteran y se repintan. Ninguna de esas cuatro cosas necesitaba el árbol de componentes; lo usaban como vehículo porque estaba ahí, y en cuanto desaparece se descubre que el vehículo era el sistema de módulos desde el principio, que es más viejo, más simple y más universal que cualquier framework. De ahí sale la conclusión que hace de esta lección la bisagra del nivel: la portabilidad de un diseño de estado no se mide por cuántos frameworks soporta la librería que elegiste, sino por cuánto de tu arquitectura sigue teniendo sentido cuando le quitas el framework entero. Si al retirar React tu modelo de estado se queda sin fuente de verdad, sin frontera de escritura o sin forma de derivar, es que no tenías una arquitectura de estado sino una convención de componentes; y si al retirarlo el módulo con tus átomos, tus funciones de escritura y tus valores derivados sigue siendo un programa completo y comprensible por sí solo, entonces lo que tienes es transferible a cualquier stack presente o futuro, porque nunca dependió de ninguno.

⚔️ Comparte un store entre dos frameworks
  1. Crea en un proyecto de Astro un módulo con un atom y un computed de Nanostores, exportando funciones de escritura y ninguna asignación directa.
  2. Monta dos islas que lo consuman: una en React y otra en Vue o Svelte, ambas en la misma página.
  3. Escribe desde una isla y comprueba que la otra se actualiza. Anota qué código tuviste que escribir para coordinarlas: debería ser ninguno.
  4. Cambia la directiva de una isla a client:visible y provoca el desfase de hidratación escribiendo antes de que aparezca. Verifica que al montarse lee el valor actual.
  5. Intenta a propósito inicializar el store durante el renderizado en servidor y razona por qué eso sería una fuga de datos entre visitantes.
  6. Sustituye Nanostores por un store de Redux puro con subscribe manual y escribe tu propio adaptador para uno de los dos frameworks. Compara el resultado y explica qué te ahorraba el adaptador oficial.