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.

Vanilla Zustand store shared across island runtimes Central vanilla store exposing getState, setState and subscribe. A React island connects with useStore and a selector; a Svelte island connects with store.subscribe. A setState call fans out notifications to both islands. zustand/vanilla store getState() · setState() subscribe(sel, fn) React island useStore(store, s => s.count) re-renders on count change only Svelte island store.subscribe(fn) $effect cleanup unsubscribes any island writes setState({ count }) fans out to all subscribers bind bind

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

  1. Single-instance check. In the console, run window.__c = cartStore from one island’s context and compare identity from another entry — or simpler, log cartStore.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.

  2. 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();
    });
  3. Over-render check. Record the Performance panel while clicking add once. Only islands reading count should show a React commit. If an island that reads only total also commits, its selector is wrong (it is selecting the whole object). A quick way to catch this in review: any useStore(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.

  4. Seed correctness. Reload with a non-empty server cart and confirm getState().count is correct on the first paint, with no 0-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.


← Back to Sharing State Across Islands