wandres.dev
ARQUITECTURA DE ISLAS · client:load, visible, only

Compartir estado entre islas y el presupuesto de JS

Las islas son raíces aisladas y no comparten estado por sí solas: para comunicarlas se usa un almacén externo diminuto como nanostores, un átomo que ambas importan y que el sistema de módulos convierte en singleton. Cerrar el nivel exige además mirar de frente el coste real de hidratar —runtime del framework, código, deserialización, CPU— y adoptar un presupuesto de JavaScript que gobierne cuántas islas y de qué peso te puedes permitir.

⏱ 16 min

Cada isla es una raíz de hidratación independiente: no cuelga de un árbol común, así que no hay un contexto ni un estado global que se propague de una a otra como ocurriría dentro de una SPA. Cuando dos islas deben coordinarse —un botón que añade al carrito y un contador en la cabecera que lo refleja— hace falta un mecanismo pensado para atravesar fronteras. La respuesta idiomática es un almacén externo minúsculo, nanostores, y entenderlo bien es también la puerta para cerrar el nivel con lo que de verdad importa: el coste de todo esto y cómo presupuestarlo.

🎯 Al terminar esta lección sabrás
  • Entender por qué las islas no comparten estado de forma automática.
  • Compartir estado entre islas con un átomo de nanostores.
  • Descomponer el coste real de hidratar una isla.
  • Adoptar un presupuesto de JavaScript que gobierne tus decisiones.

Por qué las islas no comparten estado

En una SPA, un proveedor de contexto en la raíz alcanza a cualquier componente del árbol, porque todos cuelgan de la misma raíz. En Astro no existe esa raíz: cada isla es su propia aplicación, montada por separado, con su propio ciclo de vida. Un contexto de React declarado en una isla no llega a otra isla; ni siquiera se conocen. Las props que el servidor les pasó se serializaron una vez y quedaron congeladas dentro de cada una.

Esa independencia es la fuente del rendimiento —lo viste en el nivel 1— pero impone una regla: la comunicación entre islas ocurre fuera de los frameworks, en un terreno neutral que todas puedan compartir. Ese terreno puede ser el objeto window con eventos personalizados, o —mucho mejor para estado— un almacén diseñado para ser importado desde cualquier isla, del framework que sea.

Hay una tercera vía, a veces la más simple: si dos piezas interactivas están muy acopladas, súbelas a una única isla que las englobe. Dentro de ella vuelves a tener un árbol común y todo el estado local que quieras. El coste es que esa isla es mayor y se hidrata como una sola unidad; la ganancia, que no necesitas ningún mecanismo externo. Almacén compartido, eventos o una isla que lo abarque: son tres respuestas al mismo problema, ordenadas de más desacoplado a más simple.

nanostores: un átomo compartido

nanostores es una librería de estado diminuta —cientos de bytes— y agnóstica de framework. Su unidad básica es el átomo: un valor observable que se declara en un módulo aparte y se importa donde haga falta. Defines el estado una vez, en un .ts neutral, y cada isla lo importa.

// stores/carrito.ts
import { atom } from 'nanostores';

export const total = atom(0);

export function agregar() {
  total.set(total.get() + 1);
}

Cada framework tiene un enlace ligero para suscribirse. En React es useStore, que vuelve a renderizar el componente cuando el átomo cambia; Vue, Solid y Preact tienen el suyo, y Svelte consume el átomo directamente con su contrato de store.

import { useStore } from '@nanostores/react';
import { total, agregar } from '../stores/carrito';

export default function Contador() {
  const valor = useStore(total);
  return <button onClick={agregar}>Carrito: {valor}</button>;
}

La clave de por qué esto funciona entre islas está en el sistema de módulos. Cuando dos islas importan stores/carrito.ts, el navegador carga ese módulo una sola vez y ambas reciben la misma instancia del átomo: los módulos ESM son singletons. Por eso un cambio hecho desde una isla se refleja en la otra al instante, aunque estén escritas en frameworks distintos. El átomo es el punto de encuentro que ninguna de las dos posee y que las dos comparten.

flowchart TB
A[isla A boton] -->|importa| S[atom compartido singleton]
B[isla B contador] -->|importa| S
A -->|escribe con set| S
S -->|notifica el cambio| A
S -->|notifica el cambio| B
style S fill:#89b4fa,color:#11111b
style A fill:#a6e3a1,color:#11111b
style B fill:#a6e3a1,color:#11111b

El ecosistema añade dos piezas que usarás pronto: computed, que deriva un átomo a partir de otros, y @nanostores/persistent, que respalda un átomo en localStorage para que sobreviva a las recargas —un tema, un carrito— sin cambiar la forma de consumirlo.

import { computed } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
import { total } from './carrito';

export const tema = persistentAtom('tema', 'claro');
export const vacio = computed(total, (n) => n === 0);
💡
Para coordinación puntual, basta un evento

No toda comunicación entre islas necesita un almacén. Si una isla solo debe avisar a otra de que algo ocurrió —“se abrió el carrito”, “cambió el filtro”— un CustomEvent despachado sobre window y escuchado en la otra es más ligero que introducir una dependencia. Reserva nanostores para el estado que de verdad se comparte y evoluciona; para señales sueltas, el bus de eventos del navegador ya está ahí y no pesa nada.

📝
El estado persistente se lee en el cliente

persistentAtom respalda el valor en localStorage, que solo existe en el navegador. En el servidor, el átomo arranca con su valor por defecto —“claro” en el ejemplo— y no lee nada; es al hidratar cuando recupera lo guardado. Tenlo presente para evitar un parpadeo: si el valor real difiere del predeterminado, la primera pintura mostrará el predeterminado hasta que la isla se hidrate y lo corrija.

El coste real de hidratar

Cerrar el nivel exige nombrar con precisión lo que cuesta una isla, porque solo así se decide bien. Hidratar no es gratis, y su factura tiene cuatro líneas:

  • El runtime del framework, descargado una vez y compartido entre las islas de ese framework.
  • El código del componente y de sus dependencias.
  • La deserialización de las props que viajaron en el HTML.
  • El tiempo de CPU de la hidratación, que recorre el DOM y engancha escuchadores.
⚠️
Mezclar frameworks duplica el runtime

El runtime se comparte, pero solo dentro de un mismo framework. Diez islas de React cargan el runtime de React una vez; pero una isla de React y otra de Vue cargan los dos runtimes. Mezclar frameworks en una página es a veces inevitable —o útil para migrar— pero tiene un coste fijo que se paga por cada framework presente, no por cada isla. Si puedes ceñirte a uno, la página entera comparte un solo runtime.

De esas cuatro líneas, la que más varía está en tu mano: el peso del componente y de sus dependencias. Una isla que importa una librería de fechas de doscientos kilobytes para formatear una hora cuesta eso, se vea o no. Por eso la elección de framework importa —Preact, Solid o Svelte pesan una fracción de React— y por eso una dependencia pesada dentro de una isla es más cara que la isla misma.

El presupuesto de JavaScript

La herramienta mental que ordena todo esto es un presupuesto de JavaScript: una cifra máxima de kilobytes de JS que te comprometes a no rebasar en una página, fijada de antemano y vigilada en cada cambio. Un presupuesto convierte una intuición vaga —“no mandes mucho JS”— en una restricción concreta que obliga a elegir, porque cada isla nueva consume de una bolsa finita.

🪶

Framework ligero

Preact, Solid o Svelte pesan mucho menos que React. La elección se paga en cada visita.

🧮

Menos islas

¿Es interactivo de verdad, o basta HTML y CSS? La isla más barata es la que no existe.

Diferir la carga

client:visible y client:idle reparten el coste en el tiempo o lo condicionan a la demanda.

🖥️

Server islands

Para lo dinámico pero no interactivo, server:defer da frescura con cero JavaScript de cliente.

Medir, no adivinar

Un presupuesto solo sirve si lo mides. El propio build de Astro informa del peso de cada trozo de cliente que emite, y las herramientas de análisis de bundle desglosan qué dependencia pesa dentro de cada isla. La rutina sana es revisar ese desglose cuando añades una isla o una librería: si un formateador de fechas mete cien kilobytes, lo ves antes de enviarlo, no en las métricas de campo semanas después. Adivinar el peso es como adivinar el saldo de una cuenta; el presupuesto se administra con el extracto delante. Los recortes más rentables, en orden habitual de impacto:

  • Cambiar una isla client:load por client:visible o client:idle.
  • Sustituir una dependencia pesada por una nativa o más ligera.
  • Fusionar varias islas diminutas en una para no repetir enganches.
  • Convertir en server island lo que solo muestra datos sin interacción.
  • Cuestionar si la isla debe existir: a veces HTML y CSS bastan.

Con esa cifra en la cabeza, las decisiones del nivel se ordenan solas. Antes de crear una isla, preguntas si hace falta interactividad o si un elemento nativo y algo de CSS resuelven el caso sin coste. Si hace falta, eliges el framework más ligero que sirva y la directiva que difiera o condicione su carga. Reservas client:load para lo crítico y client:only para lo que exige el navegador. Y para lo que solo es dinámico, recurres a una server island antes que a una isla de cliente. Cada decisión es un cargo contra el presupuesto, y el presupuesto es lo que impide que la suma se descontrole.

La independencia de las islas tiene un precio y una moneda para pagarlo

Todo el nivel gira sobre una misma tensión, y aquí se ve entera. La independencia de las islas es lo que hace a Astro rápido: cada una es su propia raíz, se hidrata sola, y lo que no declaras sencillamente no existe como código. Pero esa misma independencia es la que te obliga a un almacén externo para comunicarlas, porque no hay árbol común por el que fluya el estado. Lo que parece una carencia —“tengo que importar nanostores para algo que en React era un contexto”— es en realidad la factura, honesta y visible, de una arquitectura que se niega a montar un árbol global solo por comodidad. Y esa honestidad se extiende al coste: en el modelo de islas, el JavaScript no es un fondo difuso que crece sin que nadie lo note, sino una suma explícita de piezas que tú declaras una a una, cada una con su directiva y su precio. Por eso el presupuesto de JavaScript no es una restricción que Astro te impone desde fuera, sino la forma natural de razonar dentro de un sistema donde cada isla es un cargo identificable. El desarrollador que interioriza esto deja de preguntarse “¿qué framework uso?” como pregunta central y empieza a preguntarse “¿cuánto de esta página necesita de verdad ser una aplicación, y cuánto puede quedarse como el contenido que ya era?”. Compartir estado entre islas, elegir una directiva, fijar un presupuesto: son tres caras de la misma disciplina, la de tratar el JavaScript no como el material por defecto con el que se construye la web, sino como el aditivo caro que se dosifica con cuidado sobre un cimiento de HTML que casi siempre bastaba. Ese es, al final, el modo de pensar que este nivel entero venía a enseñar.

⚔️ Comparte estado y cuadra el presupuesto
  1. Crea un store con nanostores y compártelo entre dos islas —un botón y un contador— comprobando que un cambio en una se refleja en la otra.
  2. Haz que una isla sea de React y otra de Preact y observa en la red cuánto añade tener dos runtimes frente a uno solo.
  3. Fija un presupuesto —por ejemplo, 60 KB de JS— para una página y súmale el peso de cada isla hasta acercarte al límite.
  4. Toma una isla que solo muestre datos dinámicos sin interacción y sustitúyela por una server island con server:defer; recalcula el presupuesto.