Sharing State Across Islands
The isolation problem
Section titled “The isolation problem”Islands hydrate independently — that’s what makes them cheap. But it has a consequence that trips people up coming from a SPA: two islands do not share state automatically. A React useState in one island is invisible to another island, even one made from the same framework. There is no shared app tree; each island is its own little root.
Proof: the same Preact context, two islands, no sharing
Section titled “Proof: the same Preact context, two islands, no sharing”“No shared app tree” isn’t just true for useState — it’s true for createContext too, and that’s the mistake worth proving directly, because a Provider looks like it should reach everywhere. It doesn’t reach past an island’s own boundary:
import { createContext } from 'preact';
export const CountContext = createContext(0);import { CountContext } from '../context.ts';import ReaderIsland from './ReaderIsland';
export default function ProviderIsland() { return ( <CountContext.Provider value={42}> <p>ProviderIsland's own child sees: <ReaderIsland label="inside-provider-island" /></p> </CountContext.Provider> );}import { useContext } from 'preact/hooks';import { CountContext } from '../context.ts';
export default function ReaderIsland({ label }: { label: string }) { const value = useContext(CountContext); return <span data-testid={label}>{label}: {String(value)}</span>;}---import ProviderIsland from '../components/ProviderIsland.tsx';import ReaderIsland from '../components/ReaderIsland.tsx';---<div id="a"><ProviderIsland client:load /></div><div id="b"><ReaderIsland label="separate-island" client:load /></div>Built and rendered for real, the two ReaderIsland instances see different values, even though they read the exact same CountContext:
<span data-testid="inside-provider-island">inside-provider-island: 42</span><span data-testid="separate-island">separate-island: 0</span>The first ReaderIsland is ProviderIsland’s own child — same render tree, same Provider, so it sees 42. The second ReaderIsland is a separate top-level island on the same page. It never enters ProviderIsland’s tree at all, so useContext(CountContext) falls back to the context’s own default (0) — not an error, just silently the wrong value. This is true even at server-render time, before any client JS runs, because each <... client:... /> in the .astro file gets its own independent render call.
So how do a “add to cart” island and a “cart count” island in the header stay in sync, given that neither useState nor Context crosses an island boundary? You have three tools, from simplest to most powerful.
1. Pass serializable props from the server
Section titled “1. Pass serializable props from the server”If the data is known when the page renders, the simplest answer is to compute it once in the .astro file and hand it to each island as a prop. No cross-island coordination needed — the server is the shared source.
---import CartBadge from '../components/CartBadge.jsx';const initialCount = await getCartCount(Astro.request);---<CartBadge count={initialCount} client:load />This covers initial state. It doesn’t help when one island needs to update another at runtime — that’s what a store is for.
2. nanostores for cross-island shared state
Section titled “2. nanostores for cross-island shared state”When islands must react to each other at runtime, use a shared store that lives outside any single island. Astro’s docs recommend nanostores — it’s tiny, framework-agnostic, and has adapters for each framework (@nanostores/react, @nanostores/vue, …). You define the store once and any island can read and write it.
import { atom } from 'nanostores';export const cartCount = atom(0);// AddToCart.jsx (React island)import { cartCount } from '../stores/cart.js';export default function AddToCart() { return <button onClick={() => cartCount.set(cartCount.get() + 1)}>Add</button>;}// CartBadge.jsx (another React island, elsewhere on the page)import { useStore } from '@nanostores/react';import { cartCount } from '../stores/cart.js';export default function CartBadge() { const count = useStore(cartCount); // re-renders when the store changes return <span>Cart: {count}</span>;}Now clicking “Add” in one island updates the badge in a completely separate island — because both subscribe to the same store, not to each other.
flowchart TB store["nanostore: cartCount"] add["AddToCart island — writes"] --> store store --> badge["CartBadge island — reads and re-renders"]
Proven end-to-end, with @nanostores/preact
Section titled “Proven end-to-end, with @nanostores/preact”The pattern above is real, checked with the site’s own framework (@nanostores/preact’s useStore) and a real browser session, not just reasoned about:
import { atom } from 'nanostores';
export const cartCount = atom(0);import { cartCount } from '../stores/cart.ts';
export default function AddToCart() { return ( <button data-testid="add-btn" onClick={() => cartCount.set(cartCount.get() + 1)}> Add </button> );}import { useStore } from '@nanostores/preact';import { cartCount } from '../stores/cart.ts';
export default function CartBadge() { const count = useStore(cartCount); return <span data-testid="cart-badge">Cart: {count}</span>;}Both mounted as separate, top-level islands on one page (<CartBadge client:load /> in the header, <AddToCart client:load /> in the body — no parent/child relationship at all). Driven with a real browser:
document.querySelector('[data-testid=cart-badge]').textContent // "Cart: 0"// ...click [data-testid=add-btn] twice...document.querySelector('[data-testid=cart-badge]').textContent // "Cart: 2"Two clicks on AddToCart’s button update CartBadge in a completely separate island tree — this is the one thing useState/Context cannot do (proven above) and a nanostore can, because both islands subscribe to the same store object, not to each other’s component tree.
3. Slots: pass server-rendered children into an island
Section titled “3. Slots: pass server-rendered children into an island”Sometimes you don’t need shared state — you need to put server-rendered content inside an interactive shell. A framework island can accept a <slot /> the same way an .astro component does, so an interactive <Tabs> island can wrap static, server-rendered panels. The shell is interactive; the content ships as HTML with no extra island JS.
---import Accordion from '../components/Accordion.jsx';---<Accordion client:visible> <p>This paragraph is server-rendered HTML, passed into the island via its slot.</p></Accordion>Surviving a navigation: transition:persist
Section titled “Surviving a navigation: transition:persist”Props and stores both answer “how do islands on one page share state.” transition:persist answers a different question: how does one island’s own internal state survive a client-side navigation with Astro’s ClientRouter, when the next page re-renders a “new” instance of the same component?
Wrap the island in an element carrying transition:persist (a stable name, so Astro can match it up across the two pages) on both pages:
import { useState } from 'preact/hooks';
export default function PersistCounter({ start }: { start: number }) { const [count, setCount] = useState(start); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;}---import { ClientRouter } from 'astro:transitions';import PersistCounter from '../components/PersistCounter.tsx';---<html><head><ClientRouter /></head><body><h1>Page A</h1><div transition:persist="shared-counter"> <PersistCounter start={0} client:load /></div><a href="/persist-b/">Go to B</a></body></html>---import { ClientRouter } from 'astro:transitions';import PersistCounter from '../components/PersistCounter.tsx';---<html><head><ClientRouter /></head><body><h1>Page B</h1><div transition:persist="shared-counter"> <PersistCounter start={0} client:load /></div><a href="/persist-a/">Back to A</a></body></html>Both pages mount PersistCounter with start={0} — if the DOM were thrown away and rebuilt, the count would reset to 0. Proven with a real browser session instead: on Page A, three real clicks bring the button to Count: 3; clicking the real <a href="/persist-b/"> link (a ClientRouter-intercepted, client-side navigation, not a full reload):
// Page A, after 3 clicks:document.querySelector('button').textContent // "Count: 3"
// after clicking the real <a href="/persist-b/"> link:document.querySelector('h1').textContent // "Page B"document.querySelector('button').textContent // "Count: 3" — NOT reset to "Count: 0"The DOM node (and its live component instance, with its own useState) was moved across the swap rather than destroyed and recreated — start={0} on Page B’s markup was never even used. This only works because both sides use the same transition:persist value; a typo that doesn’t match loses the persistence silently and just re-mounts fresh.
Choosing among the three
Section titled “Choosing among the three”- Data known at render time, no runtime updates → props from the server.
- Islands must update each other at runtime → a nanostore.
- You need interactive chrome around static content → a slot.
Reach for a store only when you genuinely have runtime coordination between separate islands. Most “shared state” needs turn out to be initial props or slots, which ship less JavaScript.
Props vs. a store: recompute vs. live
Section titled “Props vs. a store: recompute vs. live”A prop and a store solve overlapping-looking problems, but they answer different questions about when the value is decided:
- A prop is decided once, on the server, at the moment that specific request renders. Two islands fed the same prop are consistent with each other, but only as of that render — refresh vs. don’t refresh is the only lever you have. There is no “prop changed, please re-render” path after hydration; if the server-computed value goes stale, the only fix is a fresh navigation or an explicit
fetchinside the island itself. - A store is a live, client-side value that exists independently of any one render. Every subscribed island re-renders the instant
.set()is called, with no server round-trip — proven above, no page reload between the two clicks and the badge update.
When a store is overkill
Section titled “When a store is overkill”If you find yourself reaching for a nanostore and the two islands never actually need to react to each other — only to their own data, independently — a store just adds indirection over what a prop each would do more simply. A concrete tell: if you can compute both islands’ initial values from the same request and neither one needs to change because of what the other one did, you don’t have cross-island state at all, you have two islands that each want their own prop.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet coordinates state across islands, check for these before trusting it:
- Does it wrap several islands in a single
Context.Providerand expect them all to read it? Proven above: aProvideronly reaches its own island’s render tree — a sibling island reading the same context gets the context’s default, not the provided value, with no error to flag it. - Does an “add to cart” style example use
useStatelifted into a “parent,” expecting the parent and child islands to somehow share it? If they’re separateclient:*islands (not one component tree with a real parent-child JSX relationship inside a single island), lifting state doesn’t apply — reach for a store instead. - If a nanostore is introduced, is it actually needed — do the two islands really update each other, or could each just take its own server-computed prop?
- Does
transition:persistappear without the same value on both sides of the navigation? A mismatched name silently fails to persist — the component just remounts fresh, which looks like a bug in the component rather than a typo in the directive. - Is a slot’s server-rendered content being re-implemented as a plain prop (stringified HTML, or worse, re-fetched inside the island) when the actual need was just “put this static content inside an interactive shell”?
🐛 Spot the bug
// src/components/Sidebar.tsx — wraps the whole page in a UserContext.Providerimport { UserContext } from '../context.ts';
export default function Sidebar({ user }) {return ( <UserContext.Provider value={user}> <nav>...</nav> </UserContext.Provider>);}
// src/components/Greeting.tsx — a SEPARATE top-level island elsewhere on the pageimport { useContext } from 'preact/hooks';import { UserContext } from '../context.ts';
export default function Greeting() {const user = useContext(UserContext);return <p>Welcome, {user?.name ?? 'Guest'}!</p>; // always "Guest"}Show the answer
Both components are mounted as their own top-level islands (each gets its own client:* directive in the .astro file) — proven earlier in this lesson with the identical shape (ProviderIsland/ReaderIsland): Sidebar’s UserContext.Provider only wraps Sidebar’s own render tree, not Greeting’s. Greeting is a completely separate island with a completely separate useContext(UserContext) call that falls back to whatever createContext(...) was given as a default — proven above, that’s 0/undefined-shaped, never the real user. There’s no error, just a permanently wrong “Guest”. The fix is one of the three tools: pass user as a prop to Greeting directly from the .astro file (simplest, if it’s known at render time), or put it in a nanostore if it needs to update after login without a full reload.
Mental Model
Section titled “Mental Model”Every client:* directive on the page is the root of its own, completely independent application — as independent as if you’d opened two separate browser tabs and expected them to share useState. Nothing an island’s own JSX does (a Provider, a parent useState, a ref) reaches past that island’s own boundary. The only channels that cross the boundary are the ones Astro (props, transition:persist) or a library (a store) explicitly wire up for you.
Trade-offs
Section titled “Trade-offs”| Props from the server | nanostore | <slot /> |
transition:persist |
|
|---|---|---|---|---|
| Answers | “What’s the initial value?” | “How do islands react to each other, live?” | “How does static content sit inside an interactive shell?” | “Does one island’s own state survive a navigation?” |
| Decided | Once, server-side, per request | Continuously, client-side | Once, server-side (the slotted HTML) | N/A — it’s the same instance, not new data |
| Needs a library? | No | Yes (nanostores + a framework adapter) | No | No — built into Astro’s ClientRouter |
| Ships extra JS? | No | A few hundred bytes (nanostores core) | No — content is plain HTML | No |
| Cross-island updates at runtime? | No | Yes — proven above | N/A | N/A (same island, different page) |
Common Misconceptions
Section titled “Common Misconceptions”- “Wrapping islands in the same
Context.Providershares state between them.” Proven above withProviderIsland/ReaderIsland: aProvideronly reaches its own island’s render tree. A sibling island sees the context’s default, silently. - “If two islands need the same data, that’s automatically a job for a store.” Proven above: if the data doesn’t change because of what the other island did, a shared server-computed prop is simpler and ships less JS than a store.
- “
transition:persistre-fetches or re-computes the component on the new page.” Proven above: the exact same DOM node and component instance move across the swap — the new page’s own props for that component (start={0}) are never even used. - “A nanostore needs its own framework-specific
Providerwrapping the app, like React Context.” Nanostores has no provider at all — proven above,useStore(cartCount)just works from any island that imports the same store module, because the store lives in a plain JS module, not a component tree.