Compartir estado entre islas con nanostores
Las islas están aisladas por diseño: el contexto de React o el provide de Vue no cruzan de una isla a otra, y menos entre frameworks distintos. La solución idiomática en Astro es sacar el estado fuera de todos los frameworks a un almacén agnóstico —nanostores— con sus primitivas atom, map y computed, y conectar cada isla mediante su adaptador: useStore en React, Vue o Solid, y la suscripción con dólar en Svelte. Así una isla de Svelte y otra de React observan la misma fuente de verdad y se mantienen en sincronía, permitiendo mantener las islas pequeñas e hidratar solo lo imprescindible.
El problema: las islas están aisladas
Una isla es un árbol de componentes que se hidrata por su cuenta, con su propia instancia del framework. Dos islas de React en la misma página son dos árboles separados, cada uno con su propio contexto; un Context.Provider de una no envuelve a la otra, así que su estado no se comparte. Y si las islas son de frameworks distintos, la incomunicación es total: el sistema reactivo de Svelte y el de Vue no tienen ningún canal común. El aislamiento no es un defecto, es la esencia de las islas —cada una es una región interactiva independiente—, pero deja abierta la pregunta de cómo coordinar varias.
La tentación de guardar el estado en una variable de módulo compartida resuelve el transporte pero no la reactividad: una isla que muta esa variable no tiene forma de avisar a la otra de que vuelva a pintarse. Falta el ingrediente que convierte un valor compartido en un estado observable: un mecanismo de suscripción que notifique a quien mire cuando el valor cambie. Eso es exactamente lo que aporta un almacén como nanostores.
nanostores: estado fuera de todos los frameworks
nanostores es una librería diminuta —del orden de cientos de bytes— cuya única misión es guardar estado observable fuera de cualquier framework. Su núcleo son tres primitivas. Un atom guarda un valor único; un map, un objeto de varias claves; y un computed deriva un valor de otros almacenes y se recalcula solo cuando ellos cambian. Todos exponen el mismo contrato mínimo: get para leer, set para escribir y subscribe para enterarse de los cambios.
// src/stores/carrito.tsimport { atom, computed } from 'nanostores';export const items = atom<string[]>([]);export const total = computed(items, (lista) => lista.length);export function anadir(id: string) { items.set([...items.get(), id]);}Lo esencial de este fichero es dónde vive: en un módulo .ts normal, sin una sola línea de React, Vue o Svelte. El estado no está atrapado dentro del árbol de ningún componente; existe en un plano neutral que cualquier isla puede importar. total es un computed que se mantiene en sincronía con items sin que nadie lo orqueste, y anadir es la única puerta por la que se muta el carrito. Ese módulo es la fuente de verdad; las islas serán meras vistas asomadas a él.
Cuando el estado es un objeto de varios campos independientes —un formulario, unas preferencias—, map encaja mejor que un atom: notifica los cambios clave por clave, de modo que una isla suscrita a un campo no se repinta cuando cambia otro. Y los computed se encadenan: puedes derivar de un map y, de ese computed, derivar otro, tejiendo un pequeño grafo de estado cuya única raíz son las escrituras que haces con set. El resto —propagación, recálculo, notificación— lo resuelve el almacén sin que ninguna isla intervenga.
Un adaptador por framework, una misma fuente
Cada framework se conecta al almacén con un adaptador oficial que traduce la suscripción de nanostores a su propio sistema de reactividad. En React, Preact, Vue y Solid el puente es un hook llamado useStore; en Svelte, el propio contrato de store del framework permite suscribirse con el prefijo de dólar, sin hook.
// Badge.jsx de Reactimport { useStore } from '@nanostores/react';import { total } from '../stores/carrito';export default function Badge() { const n = useStore(total); return <span class="badge">{n} en el carrito</span>;}<!-- Boton.svelte --><script> import { items, anadir } from '../stores/carrito';</script><button on:click={() => anadir('cafe')}>Añadir café</button><p>{$items.length} productos</p>El useStore(total) de React devuelve el valor actual y vuelve a renderizar el Badge cuando cambia; el $items de Svelte se suscribe automáticamente y repinta al mutar. Ahora colócalos en la misma página desde una plantilla .astro, cada uno como isla independiente.
---import Boton from '../components/Boton.svelte';import Badge from '../components/Badge.jsx';---<Boton client:load /><Badge client:visible />Cuando pulsas el botón de Svelte, anadir muta el atom; el computed recalcula total; el adaptador de React lo notifica al Badge, que se repinta con el número nuevo. Una isla de Svelte acaba de actualizar una isla de React sin que ninguna sepa de la existencia de la otra, porque ambas solo conocen el almacén. Ese es el mecanismo entero, y funciona entre cualquier combinación de frameworks porque el estado vive por debajo de todos ellos.
Merece la pena ver por qué esto funciona y un contexto de React no. El almacén no es reactivo para un framework concreto; es reactivo en abstracto, con un contrato de suscripción que cualquier adaptador puede consumir. El de React traduce ese subscribe a un re-render, el de Svelte al mecanismo de store nativo del lenguaje, el de Vue a una ref. Cada adaptador es un cable finísimo entre un protocolo neutral y la reactividad de un framework, y como todos se enchufan al mismo protocolo, todos perciben el mismo latido. El contexto de React fracasa aquí por lo contrario: es reactivo solo dentro de su propio árbol, y una isla es un árbol distinto.
Hidratar solo lo imprescindible
El estado externo no solo resuelve la comunicación: cambia cómo diseñas las islas. Sin él, la tentación es agrandar una isla para que abarque todo lo que comparte estado, arrastrando a la interactividad —y al coste de JavaScript— zonas que solo necesitan leer un dato. Con un almacén compartido puedes hacer justo lo contrario: fragmentar la interfaz en islas mínimas, cada una suscrita únicamente a la porción del estado que le concierne, y darle a cada una la directiva más perezosa que admita.
---import Total from '../components/Total.jsx'; // solo lee el totalimport Acciones from '../components/Acciones.svelte'; // muta el carrito---<Acciones client:idle /><p>Tu compra suma <Total client:visible /> artículos.</p>Aquí Acciones se hidrata cuando el navegador queda ocioso y Total solo cuando entra en pantalla; entre medias, todo el resto de la página es HTML estático. Ninguna de las dos islas necesita envolver a la otra ni conocerla: ambas se coordinan a través del almacén. El resultado es un sitio donde el JavaScript crece por porciones pequeñas y justificadas, en lugar de por una gran isla que hidrata de golpe zonas que podrían haber seguido siendo texto.