Using Nanostores for Cross-Island State

Nanostores is close to the ideal shape for cross-island state: a few hundred bytes, no framework dependency, and official binding adapters for React, Vue, Svelte, Solid, and Preact. That combination matters under Islands Architecture because each island may be built in a different framework yet must read the same value, and every byte you add is shipped against the very budget islands exist to protect. This guide shows how to define a shared atom or map, seed it from the server, and bind it into islands of different frameworks on one Astro page. It is a concrete implementation of the transport comparison in sharing state across islands; if you already standardise on a larger store, compare it with how to share Zustand state across islands.

Prerequisites


One Atom, Many Framework Islands

The store is a module singleton. Each adapter (useStore in React, useStore in Vue, the auto-subscription in Svelte) reads the same atom, and setKey fans out only to listeners of the changed key.

Nanostores map shared across multi-framework Astro islands A central nanostores map with keys count and total. A React island binds via at-nanostores-react, a Vue island via at-nanostores-vue, and a Svelte island via the svelte binding. A setKey call on count notifies only the islands that read count. nanostores map $cart = map({count,total}) setKey('count', n) React island useStore($cart) Vue island useStore($cart) Svelte island $_cart auto-subscribe any island writes setKey('count', n)

Implementation Steps

Step 1 — Define the shared store

Goal: One map visible to every island, with per-key subscription support.

// stores/cart.ts — a module singleton imported by every island.
// map() is chosen over atom() so that setKey notifies only listeners of the
// changed key — a theme write never re-renders islands reading only `count`.
import { map, computed } from 'nanostores';

export interface Cart { count: number; total: number; items: { id: string; price: number }[] }

export const $cart = map<Cart>({ count: 0, total: 0, items: [] });

// A computed store derives values; islands reading it update only when its
// dependencies change. Cheap, and keeps derivation out of each island.
export const $isEmpty = computed($cart, (c) => c.count === 0);

export function addItem(item: { id: string; price: number }) {
  const items = [...$cart.get().items, item];
  // setKey updates one key and notifies only that key's listeners.
  $cart.setKey('items', items);
  $cart.setKey('count', items.length);
  $cart.setKey('total', items.reduce((n, i) => n + i.price, 0));
}

Expected output: $cart.get().count returns 0 after import in any island; $isEmpty.get() returns true.

The choice of map over atom is the load-bearing decision. An atom holds one value and notifies every listener on every write, so if the cart were an atom, changing total would re-run listeners that only read count. A map tracks its keys independently: setKey('total', n) notifies only the listeners registered for total. In an islands page where the header badge reads count and a subtotal island reads total, that per-key granularity means each island re-renders solely on the field it displays. The computed store extends the same idea downstream — $isEmpty recomputes only when count changes, and islands that bind to $isEmpty never see the intermediate churn. Deriving values in the store rather than in each island also keeps the derivation logic in one place instead of duplicated across three frameworks.

A subtle but important property: nanostores are lazy. An atom or map has no active listeners until an island subscribes, and it tears its internal machinery down when the last listener leaves. That means a store imported by an island that never mounts costs almost nothing, which suits the islands model where many components ship but only some hydrate.


Step 2 — Seed the store from the server

Goal: All islands start from the same server snapshot with no per-island fetch.

---
// Cart.astro layout fragment — server only.
const cart = await loadCart(Astro.request);
---
<script type="application/json" id="cart-seed" set:html={JSON.stringify(cart)} />
// stores/hydrateCart.ts — side-effect import at the top of each island entry.
import { $cart } from './cart';
const raw = document.getElementById('cart-seed')?.textContent;
// map.set replaces the whole object once; subsequent writes use setKey.
if (raw) $cart.set(JSON.parse(raw));

Expected output: After hydration every island reads the seeded count on its first render, with no 0-to-N flash.


Step 3 — Bind islands with the framework adapters

Goal: Read the same store from React, Vue, and Svelte islands.

// islands/CartBadge.tsx — React island via @nanostores/react.
import { useStore } from '@nanostores/react';
import { $cart } from '../stores/cart';
import '../stores/hydrateCart';

export default function CartBadge() {
  // useStore re-renders this island only when $cart changes. Reading one key
  // via a computed store would scope it further; here the badge needs count.
  const { count } = useStore($cart);
  return <span className="badge">{count}</span>;
}

<script setup>
import { useStore } from '@nanostores/vue';
import { $cart } from '../stores/cart';
import '../stores/hydrateCart';
const cart = useStore($cart); // a ref that stays in sync with the shared map
</script>

<script>
  import { $cart as cart, addItem } from '../stores/cart';
  import '../stores/hydrateCart';
  // Aliasing on import lets Svelte's $ auto-subscription read `$cart` cleanly:
  // nanostores implement the Svelte store contract, so no adapter is needed.
</script>
<button onclick={() => addItem({ id: crypto.randomUUID(), price: 12 })}>
  Add — {$cart.count} items
</button>

Expected output: Adding from the Svelte drawer updates the React badge and the Vue total simultaneously — three frameworks, one atom.

Each adapter is doing the same job with framework-native ergonomics: @nanostores/react’s useStore wires the atom into React’s subscription model via useSyncExternalStore under the hood; @nanostores/vue’s useStore returns a ref that Vue’s reactivity tracks; and Svelte needs no adapter at all because a nanostore already satisfies Svelte’s store contract (subscribe returning an unsubscribe), so the $ prefix works directly. The store code is identical across all three — only the binding line changes. This is exactly the situation Islands Architecture creates and few state libraries handle gracefully: several UI frameworks coexisting on one document, each hydrating its own island, all needing to agree on one value.


Step 4 — Scope re-renders with computed or listenKeys

Goal: Ensure islands only react to the keys they read.

// A per-key derived store; islands importing $count re-render only on count.
import { computed } from 'nanostores';
import { $cart } from './cart';
export const $count = computed($cart, (c) => c.count);

Expected output: The badge bound to $count does not re-render when only total changes, confirmed in the profiler.

There are two ways to scope reads, and they suit different consumers. A computed store is the right tool when the scoped value is itself useful to several islands — you define $count once and any island can bind to it. listenKeys is better inside imperative, non-framework code (an analytics hook, a URL-sync subscriber) that wants to react to specific keys without creating a derived store. Reaching for useStore($cart) and reading one field off the returned object is the anti-pattern: it subscribes to the whole map and re-renders on every key, discarding the per-key granularity that made map worth choosing in the first place.


Verification

  1. Cross-framework propagation. Load a page with the React, Vue, and Svelte islands. Trigger addItem from Svelte and confirm all three update in the same frame. This proves the atom is one instance across runtimes.

  2. Per-key scope. Subscribe with listenKeys and assert selectivity in a test:

    import { $cart } from '../stores/cart';
    import { listenKeys } from 'nanostores';
    test('setKey(total) does not notify count listeners', () => {
      const countSpy = vi.fn();
      const off = listenKeys($cart, ['count'], countSpy);
      $cart.setKey('total', 99);      // change an unrelated key
      expect(countSpy).not.toHaveBeenCalled();
      off();
    });
  3. Seed correctness. Reload with a non-empty server cart; every island should paint the correct count first, no flash. Because the seed runs at module-evaluation time and nanostores are lazy, the value is in place before the first adapter subscribes — if you see a flash, the seed import was code-split behind the island rather than run eagerly in the entry.

  4. Bundle weight. Inspect the shared chunk in the build output and confirm nanostores appears once, adding only a few hundred bytes. Duplicated store code means the module was bundled per island — check import specifiers.


Troubleshooting

One island updates but the others do not

Root cause: The store module resolved to two different files (mismatched aliases, or a .ts/.js duplication), so each island holds its own map. Writes go to one instance; the others never hear them.

Fix: Import $cart from a single canonical specifier everywhere. In Astro, keep the store in src/stores/ and import via the same relative or aliased path in every island. Confirm the bundle emits nanostores and the store module exactly once.

All islands re-render on every write

Root cause: Islands bind to the whole $cart with useStore($cart) and read multiple keys, so any setKey re-renders them. This is expected for useStore on a map; narrow it deliberately.

Fix: Expose per-key computed stores ($count, $total) and bind islands to the derived store they actually need, or use listenKeys in non-framework code. This limits notifications to the keys each island reads.

Svelte island shows a stale value after hydration

Root cause: The seed import ran after the Svelte component’s first auto-subscription read, so $$cart captured the initial {count:0} before $cart.set(seed) applied.

Fix: Import the seed module as a side effect before the store is first read in the entry, and ensure hydrateCart is not lazy-loaded. The $cart.set(JSON.parse(raw)) must execute during module evaluation, before the island mounts.


← Back to Sharing State Across Islands