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.
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>
{{ cart.total }}
<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
-
Cross-framework propagation. Load a page with the React, Vue, and Svelte islands. Trigger
addItemfrom Svelte and confirm all three update in the same frame. This proves the atom is one instance across runtimes. -
Per-key scope. Subscribe with
listenKeysand 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(); }); -
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.
-
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.
Related
- Sharing State Across Islands — the full transport comparison this nanostores recipe implements.
- How to Share Zustand State Across Islands — a heavier store with the same module-singleton topology and selector subscriptions.
- Cross-Boundary Prop Passing in Islands Architecture — how the JSON seed is serialised and delivered to hydrate the atom.
← Back to Sharing State Across Islands