Sharing State Across Islands
A single island is easy: it owns its state, hydrates in isolation, and never talks to anything else. The moment two islands must agree on something — a cart badge in the header and the cart drawer in the footer, a theme toggle and every themed widget, a filter bar and the results grid — the isolation that makes islands fast becomes the problem you have to engineer around. Each island is hydrated by its own root call, runs its own framework runtime, and cannot see another island’s component tree. There is no shared provider, no ambient context, no parent to lift state into. This guide covers the four transports that actually work across those boundaries — external stores, a custom-event bus, URL/storage synchronisation, and shared signals — and the trade-offs each imposes on coupling, re-render cost, and serialisation. It sits under server–client boundaries and state synchronisation, which frames the broader problem of keeping server-rendered and client-activated code in agreement.
Concept Definition & Scope
“Shared state” across islands means a value that more than one independently hydrated component must both read and, usually, write, where a change made by one island becomes visible to the others without a page reload. This is a narrower problem than global application state in a single-page app. In an SPA there is one runtime, one component tree, and a provider at the root can reach everything. Under Islands Architecture there is no such root — the server–client boundary is crossed once per island, each island mounts its own tree, and the DOM between them is inert static HTML.
The consequence is precise: any mechanism that relies on a shared component ancestor — React context, Svelte context, Vue provide/inject, Solid context — is unavailable across islands. Those APIs are scoped to a single runtime tree and stop at its root. The only state visible to two islands is state that lives outside both trees: in a plain JavaScript module, in the URL, in sessionStorage/localStorage, in a BroadcastChannel, or dispatched as a DOM event on a shared ancestor node.
Scope boundaries for this guide: it covers reactive state that changes at runtime and must propagate between islands on the same document. It does not cover one-off server-to-client data handoff at mount — that is cross-boundary prop passing, which serialises props into each island once and does not keep them in sync afterwards. It also does not replace event delegation, which routes raw DOM interactions to islands; a shared store is what the delegated handlers usually write into.
The design tension throughout is coupling. A shared store is the loosest possible coupling between islands — they agree on a data shape, not on each other’s existence — but it is still coupling, and every transport below trades coupling against re-render cost, serialisation cost, and how much survives a page navigation.
Technical Mechanics
The foundational move is the same for every store-based approach: put the state in a module that is not framework-specific, so importing it from two island entry points yields the same instance. Bundlers dedupe a shared module into a single chunk, so the store is constructed exactly once regardless of how many islands import it.
Here is a framework-neutral vanilla store — a minimal atom with selector-free subscription — that any island can consume:
// stores/cart.js — a module singleton. Imported by every island entry point,
// this file evaluates ONCE; the bundler dedupes it into one shared chunk,
// so `cart` is the same object across the header, drawer, and CTA islands.
function createAtom(initial) {
let value = initial;
const subs = new Set();
return {
get: () => value,
set(next) {
if (Object.is(next, value)) return; // skip no-op writes — prevents needless notifications
value = next;
// notify AFTER commit so every subscriber reads the same new value
subs.forEach((fn) => fn(value));
},
subscribe(fn) {
subs.add(fn);
fn(value); // push current value immediately on subscribe
return () => subs.delete(fn); // caller returns this from its cleanup hook
},
};
}
// One authoritative cart, living outside every island runtime.
export const cart = createAtom({ items: [], count: 0, total: 0 });
// Hydrate once from the server-serialised seed, before any island subscribes.
const seedEl = document.getElementById('cart-seed');
if (seedEl) cart.set(JSON.parse(seedEl.textContent));
An Astro-hosted React island consumes it through useSyncExternalStore, the API built precisely for subscribing a React tree to state that lives outside React:
// islands/CartBadge.tsx — a React island mounted by Astro with client:idle.
// It does not own the cart; it is a *view* onto the shared atom.
import { useSyncExternalStore } from 'react';
import { cart } from '../stores/cart.js';
export default function CartBadge() {
// useSyncExternalStore keeps this island's tree in sync with the external
// atom. The third arg (getServerSnapshot) matches SSR output so hydration
// does not mismatch — critical across the island boundary.
const count = useSyncExternalStore(
cart.subscribe, // subscribe: returns the unsubscribe fn
() => cart.get().count, // client snapshot
() => cart.get().count, // server snapshot — identical, no mismatch
);
return <span class="badge" data-count={count}>{count}</span>;
}
A Svelte 5 island writing to the same atom uses runes plus an effect that mirrors the store into a rune and pushes writes back out:
<script>
import { cart } from '../stores/cart.js';
// $state is this island's local mirror of the shared atom.
let snapshot = $state(cart.get());
// Subscribe on mount; $effect's returned function is the cleanup — it runs
// when the island unmounts, so we never leak a subscriber.
$effect(() => cart.subscribe((v) => (snapshot = v)));
function addItem(item) {
const items = [...snapshot.items, item];
// Writing to the atom notifies EVERY island, including the React badge.
cart.set({ items, count: items.length, total: items.reduce((s, i) => s + i.price, 0) });
}
</script>
{#each snapshot.items as item}
<div class="line">{item.name}</div>
{/each}
The mechanism generalises: the store exposes get/set/subscribe, each framework binds to it with its own external-store primitive (useSyncExternalStore, a Svelte $effect, Vue’s onScopeDispose, a Solid createEffect), and no island imports another island. Purpose-built libraries — covered in how to share Zustand state across islands and using nanostores for cross-island state — replace the hand-rolled atom with selector subscriptions, computed values, and framework adapters, but the topology is identical.
Choosing a Transport
Five approaches cover essentially every cross-island requirement. They differ in how tightly islands couple, whether a write forces re-renders in islands that did not read the changed slice, what has to be serialisable, and whether state survives a full navigation.
| Approach | Coupling | Re-render granularity | Serialisation requirement | Survives navigation | Best fit |
|---|---|---|---|---|---|
| External store (nanostores / Zustand vanilla) | Loose — shared data shape only | Per-selector; islands re-render only on slices they read | Seed serialised once into HTML; runtime values in memory | No (in-memory) | Cart, session, theme, filters — anything read by 2+ islands |
Custom-event bus (CustomEvent on a shared node) |
Very loose — publisher and subscriber never import each other | Coarse — each subscriber decides what to do per event | detail payload must be structured-clone-safe |
No | Fire-and-forget signals: “item added”, “modal opened”, analytics |
| URL / query-string sync | Loose via shared param contract | Coarse — islands re-read on popstate |
Values must be string-encodable | Yes — shareable, bookmarkable, back-button safe | Filters, tabs, pagination, anything deep-linkable |
Storage sync (localStorage + storage event) |
Loose | Coarse — storage event fires per key |
JSON-serialisable only | Yes — persists across reloads and tabs | Theme, auth token presence, dismissed banners, cross-tab sync |
| Shared signals (Preact/Solid signals module) | Loose | Finest — automatic dependency tracking, per-read | Seed serialised once | No | Many islands reading overlapping derived values with minimal glue |
Read the table by starting from the requirement rather than the technology. If the state must be deep-linkable or survive the back button, URL sync is the only option that gives you that for free — no other approach reconstructs state on a fresh document load. If the state must persist across reloads or synchronise between tabs, storage sync is mandatory because in-memory stores reset on navigation. If neither of those is required and the concern is minimising re-render blast radius, an external store with selectors or shared signals wins. The custom-event bus is the right tool only for transient notifications where no island needs to read back a value later — the moment a late-hydrating island needs the current state, an event it missed is gone, and you need a store.
A common and correct design combines them: an external store as the runtime source of truth, seeded from serialised HTML, with a thin URL-sync layer that mirrors the deep-linkable slice into the query string so navigation and sharing work.
Step-by-Step Integration
The following builds a cart shared across three islands using an external store seeded from the server.
-
Serialise the initial state on the server. Render the authoritative value into a
<script type="application/json">block once. Do not fetch it separately in each island — that produces a request waterfall and lets islands start from different snapshots.--- // Layout.astro — runs on the server only. const cart = await loadCart(Astro.request); // your data source --- <script type="application/json" id="cart-seed" set:html={JSON.stringify(cart)} /> <slot /> -
Create the store as a module singleton. Put it in its own file (
stores/cart.jsabove) with no heavy imports, so the shared chunk stays small. Hydrate it from#cart-seedat import time, before any island subscribes. -
Subscribe from each island’s mount, unsubscribe on cleanup. Every island binds with its framework’s external-store primitive and returns the unsubscribe function from its cleanup hook (
useSyncExternalStorehandles this for React;$effectreturn for Svelte;onScopeDisposefor Vue). A missing unsubscribe is the most common leak — islands that unmount on route change keep receiving notifications and re-run work against detached nodes. -
Route writes through the store, never island-to-island. When the drawer adds an item it calls
cart.set(...); it never reaches into the badge island. This keeps coupling at the data-shape level. If a raw DOM click on a static “Add” button must trigger the write before the drawer hydrates, wire it through event delegation so the delegated handler callscart.set(...). -
Mirror the deep-linkable slice to the URL if needed. For filters or tabs, add a small subscriber that writes the relevant slice to
history.replaceStateand apopstatelistener that writes back into the store. Keep this one-directional-at-a-time (guard against feedback loops with a flag) so a URL change and a store change do not ping-pong. -
Guard against write echoes. In
set, skip no-op writes with anObject.ischeck (shown in the atom above). Without it, an island that recomputes and writes an equal value on every notification creates an infinite notify loop across islands.
Measurement & Validation
Shared state introduces two failure signatures you must measure for directly: notifications that do not propagate, and notifications that propagate too widely (re-render storms).
Propagation correctness. Assert that one write triggers exactly one notification in every other island and zero redundant re-renders. Instrument the store:
// Wrap set() during development to count notifications per write.
const _set = cart.set;
cart.set = (next) => {
performance.mark('cart:write:start');
_set(next);
performance.mark('cart:write:end');
performance.measure('cart:write', 'cart:write:start', 'cart:write:end');
// A single measure entry per user action; multiple entries = a write echo loop.
};
In a test, mount two islands, dispatch one action, and assert the subscriber in the second island fired exactly once:
// Vitest — jsdom. Proves a write in Island B reaches Island A once.
import { cart } from '../stores/cart.js';
test('one write notifies each other island exactly once', () => {
const seen = [];
const off = cart.subscribe((v) => seen.push(v.count)); // stand-in for Island A
seen.length = 0; // drop the immediate push
cart.set({ items: [{ price: 5 }], count: 1, total: 5 });
expect(seen).toEqual([1]); // exactly one notification
off();
});
Re-render blast radius. Open the Performance panel, record while triggering one shared write, and filter the flame chart for your framework’s render function. A correctly scoped store shows a render only in islands that read the changed slice. If islands that read an unchanged slice also render, your subscription is not selector-scoped — switch to a store with computed atoms or selectors (see the nanostores and Zustand guides). Confirm with the React Profiler’s “why did this render” that the commit was caused by the store subscription and not by an unrelated prop change.
Hydration mismatch check. Because useSyncExternalStore’s server snapshot must equal the client snapshot, log a warning if the seed differs from what the store held at first client read. A mismatch means the store hydrated after React’s first pass — move the seed-hydration line above the island imports.
Failure Modes
1. Context used instead of a module singleton. A team wraps islands in what looks like a shared provider and finds writes in one island never reach another. The root cause is that each island is a separate runtime tree, and a provider only reaches its own descendants. Fix: move the value out of the tree entirely into a module singleton.
- // ❌ Only descendants of THIS island's tree can read it.
- const CartContext = createContext(initialCart);
+ // ✅ A module singleton is visible to every island's runtime.
+ import { cart } from '../stores/cart.js';
2. Re-render storm from a monolithic store. A single store object notifies all subscribers on every write, so changing theme re-renders the cart island and vice versa. Symptom: TBT climbs after adding shared state, and the flame chart shows unrelated islands rendering on each action. Fix: split into independent atoms or use selector subscriptions so each island subscribes to a slice.
// ❌ one blob → every write notifies everyone
export const app = createAtom({ theme: 'light', cart: {…}, user: {…} });
// ✅ independent atoms → theme writes never touch cart subscribers
export const theme = createAtom('light');
export const cart = createAtom({ items: [], count: 0, total: 0 });
3. Leaked subscriptions on unmount. An island that route-navigates away keeps its subscriber registered; the store still notifies it, and callbacks run against detached DOM, slowly leaking memory and CPU. Fix: always return the unsubscribe function from the framework’s cleanup hook — useSyncExternalStore does this automatically; manual subscribe calls must be paired with the returned disposer.
Related
- How to Share Zustand State Across Islands — use vanilla Zustand’s
createStoreas the module singleton with selector subscriptions across island runtimes. - Using Nanostores for Cross-Island State — atoms, maps, and computed values with first-class Astro and framework adapters.
- Event Delegation in Partially Hydrated Apps — route raw DOM interactions from static nodes into the shared store before islands hydrate.
- Optimistic Updates Without Full Hydration — apply local deltas to a shared store immediately and reconcile with the server afterwards.
- Cross-Boundary Prop Passing in Islands Architecture — the one-time server-to-client handoff that seeds a shared store at mount.