How to Share Zustand State Across Islands
Zustand is a natural fit for cross-island state because its store is not, at its core, a React thing — createStore from zustand/vanilla returns a plain object with getState, setState, and subscribe. That makes it visible to any island runtime, which is exactly what you need when each island is hydrated by its own root and cannot see another island’s tree. Teams already using Zustand inside a single-page app often assume it will not survive the move to islands, because the familiar create() hook is unusable outside React; the vanilla store underneath it is the piece that ports directly, and this guide is really about reaching for that lower layer. This guide shows how to define one vanilla store as a module singleton, seed it from the server, and bind it from React and non-React islands with selector subscriptions. It is the concrete, library-specific version of the patterns in sharing state across islands; if you want a smaller footprint with built-in Astro adapters, compare it against using nanostores for cross-island state.
Prerequisites
How the Store Spans Runtimes
The store lives in a module outside every island. React islands bind through useStore; non-React islands call subscribe directly. One instance, many views.
Implementation Steps
Step 1 — Create the vanilla store as a module singleton
Goal: One store instance visible to every island, with selector subscriptions enabled.
// stores/cartStore.ts — imported by every island entry point.
// createStore (vanilla) returns a plain store, NOT a React hook, so any
// framework runtime can read it. subscribeWithSelector lets non-React
// islands subscribe to a single slice instead of the whole state.
import { createStore } from 'zustand/vanilla';
import { subscribeWithSelector } from 'zustand/middleware';
export interface CartState {
items: { id: string; price: number }[];
count: number;
total: number;
addItem: (item: { id: string; price: number }) => void;
}
export const cartStore = createStore<CartState>()(
subscribeWithSelector((set) => ({
items: [],
count: 0,
total: 0,
// Actions live on the store so every island triggers writes the same way.
addItem: (item) =>
set((s) => {
const items = [...s.items, item];
return { items, count: items.length, total: items.reduce((n, i) => n + i.price, 0) };
}),
})),
);
Expected output: cartStore.getState().count returns 0 immediately after import in any island. Importing the module from two entry points yields the same object (cartStore reference is identical).
Two decisions in this file matter for cross-island behaviour. First, createStore (not create) returns a bare store — create from the zustand entry point returns a React hook, which is useless to a Svelte or Vue island because it can only be called inside a React render. Second, keeping the action functions (addItem) inside the store rather than in each island means every island triggers the same transition through one code path; if two islands each rolled their own “add” logic they would eventually diverge on how total is computed. The store is the single writer of the transition; islands only invoke it.
If you need the cart to survive reloads or synchronise across tabs, wrap the initializer in the persist middleware (persist(subscribeWithSelector(...), { name: 'cart' })). Persistence composes cleanly here because the store is already a module singleton — the middleware rehydrates the one instance every island shares, rather than fighting per-island copies.
Step 2 — Seed the store from the server once
Goal: Every island starts from an identical snapshot without each one fetching.
---
// CartProvider.astro — server-only. Emits the seed into the HTML.
const cart = await loadCart(Astro.request);
---
<script type="application/json" id="cart-seed" set:html={JSON.stringify(cart)} />
// stores/hydrateCart.ts — imported at the top of each island entry, runs once.
import { cartStore } from './cartStore';
const seed = document.getElementById('cart-seed')?.textContent;
// setState merges; we replace the data slices but keep the action functions.
if (seed) cartStore.setState(JSON.parse(seed));
Expected output: After hydration, cartStore.getState().count equals the server value on the very first read in every island — no flash from 0 to the real count.
Step 3 — Bind a React island with a selector
Goal: Re-render the React island only when its slice changes.
// islands/CartBadge.tsx — React island, its own hydrateRoot.
// useStore from 'zustand' binds the vanilla store to THIS React tree.
import { useStore } from 'zustand';
import { cartStore } from '../stores/cartStore';
import '../stores/hydrateCart'; // side-effect import: seed before first read
export default function CartBadge() {
// The selector scopes re-renders: this island commits only when count changes,
// not when unrelated slices (e.g. a theme atom) update.
const count = useStore(cartStore, (s) => s.count);
return <span className="badge">{count}</span>;
}
Expected output: The badge shows the seeded count on load and increments the instant any other island calls addItem, with the React Profiler showing a commit only on count changes.
The selector is doing real work here, and it is easy to under-appreciate. useStore(cartStore) with no selector subscribes the component to the whole state object, so React re-renders the badge whenever any field changes — including items, which the badge never displays. useStore(cartStore, s => s.count) narrows the subscription to a single scalar; Zustand compares the selected value with Object.is between notifications and skips the re-render when it is unchanged. In an islands app where the whole justification is minimal client work, that difference is the line between a store that helps and a store that quietly reintroduces the re-render cost islands were meant to remove.
Step 4 — Bind a non-React island with subscribe
Goal: Read and write from a Svelte/Vue/vanilla island using the same store.
<script>
import { cartStore } from '../stores/cartStore';
import '../stores/hydrateCart';
let count = $state(cartStore.getState().count);
// subscribeWithSelector: fire only when count changes. The returned
// unsubscribe is handed to $effect cleanup so we never leak on unmount.
$effect(() => cartStore.subscribe((s) => s.count, (c) => (count = c)));
// Trigger the shared action; the React badge updates too.
const add = () => cartStore.getState().addItem({ id: crypto.randomUUID(), price: 9 });
</script>
<button onclick={add}>Add ({count})</button>
Expected output: Clicking the drawer button updates both the drawer’s own label and the React badge in the header, proving the write crossed the runtime boundary.
Note the asymmetry between the two bindings. The React island used useStore, a hook that manages subscription and unsubscription with the component lifecycle for you. The Svelte island called subscribe directly, so it owns the teardown — the returned unsubscribe function must reach $effect’s cleanup, or the drawer keeps receiving notifications after it unmounts on a route change. This is the single most common leak with a shared vanilla store: the framework that gives you a hook hides the teardown, and the framework where you subscribe by hand makes it your responsibility. Treat every manual subscribe as paired with a disposer in the same breath.
Verification
-
Single-instance check. In the console, run
window.__c = cartStorefrom one island’s context and compare identity from another entry — or simpler, logcartStore.getState()after a write from island B and confirm island A’s selector fired. Two different objects means the module was bundled twice; see troubleshooting. -
Propagation test. In Vitest with jsdom, subscribe a spy, dispatch
addItem, and assert exactly one notification:import { cartStore } from '../stores/cartStore'; test('addItem notifies count subscribers once', () => { const spy = vi.fn(); const off = cartStore.subscribe((s) => s.count, spy); cartStore.getState().addItem({ id: 'a', price: 5 }); expect(spy).toHaveBeenCalledTimes(1); expect(cartStore.getState().count).toBe(1); off(); }); -
Over-render check. Record the Performance panel while clicking add once. Only islands reading
countshould show a React commit. If an island that reads onlytotalalso commits, its selector is wrong (it is selecting the whole object). A quick way to catch this in review: anyuseStore(cartStore)call without a second argument is a whole-store subscription and should be treated as a red flag unless the component genuinely renders every field. -
Seed correctness. Reload with a non-empty server cart and confirm
getState().countis correct on the first paint, with no0-to-N flash — that flash means the seed import runs after the first render.
Troubleshooting
A write in one island never updates the other island
Root cause: The store module was bundled separately into each island’s chunk, so createStore ran twice and each island holds a different instance. This happens when the store is defined inside a component file that each entry imports transitively rather than in its own shared module, or when path aliases resolve to two different files.
Fix: Move the store into a dedicated module (stores/cartStore.ts) that both entries import by the same specifier. Verify the bundler emits it once — in a Rollup/Vite build, check that the store appears in a single shared chunk, not inlined per island.
// ❌ store created inside an island component → one instance per island
// ✅ store created in its own module → one shared instance
export const cartStore = createStore(/* … */);
Every island re-renders on unrelated writes
Root cause: Islands subscribe to the whole state object (useStore(cartStore) with no selector, or store.subscribe(listener) without a selector), so any setState notifies them regardless of which slice changed.
Fix: Always pass a selector. In React use useStore(cartStore, s => s.count); in vanilla islands use the subscribeWithSelector form store.subscribe(s => s.count, listener). Split unrelated concerns into separate stores if a single store mixes independent domains.
getState() returns 0 on first paint despite a non-empty server cart
Root cause: The seed-hydration import runs after the island’s first render, so the first read sees the store’s initial values before setState(seed) applies.
Fix: Import the seed module as a side effect at the very top of each island entry, above the component import, so setState(JSON.parse(seed)) executes before the first getState/useStore read. Confirm there is no dynamic import deferring the seed.
Related
- Sharing State Across Islands — the transport comparison this Zustand recipe is one implementation of.
- Using Nanostores for Cross-Island State — a lighter alternative with built-in Astro and framework adapters.
- Cross-Boundary Prop Passing in Islands Architecture — how the serialised seed reaches the client to hydrate the store.
← Back to Sharing State Across Islands