
How to share state between islands in Astro?
To share state between islands in Astro, put the state in a small store that lives outside any component, in its own module, and import that module into every island that needs it. Astro recommends Nano Stores for this: you create an atom or map in a file like src/stores/cart.ts, call .set() from one island, and every other island subscribed to it re-renders, whether it's written in React, Vue, Svelte, Preact, Solid or a plain <script> tag. This works because the browser loads that store module once and every island imports the same instance. Context providers don't work here, because each island is hydrated as a separate app.
Sharing state is one of the first problems people hit after splitting a page into islands. A cart button in the header needs to know what the "Add to cart" button in the main content just did, and React Context or Vue's provide() can't bridge the gap. In this article you'll learn why islands are isolated, how to set up Nano Stores, how to read and write the same store from React, Vue, Svelte and Astro scripts, how to derive and persist state, and which pitfalls to avoid around server rendering.
Why Islands Can't Share Context
Each component with a client:* directive is hydrated on its own, as explained in the post on Astro's island architecture. Two React islands on the same page are two separate React roots, even though they share one copy of the React library.
You might try wrapping them in a provider in your layout:
---
// This does NOT share state between the islands
import { CartProvider } from '../components/CartContext';
import CartButton from '../components/CartButton';
import AddToCart from '../components/AddToCart';
---
<CartProvider>
<CartButton client:load />
<AddToCart client:load />
</CartProvider>
This doesn't work. CartProvider has no directive, so Astro renders it to static HTML on the server and discards it. In the browser, each island is hydrated independently with no provider above it.
What the islands do share is the browser's module graph. If two islands import the same JavaScript module, they get the same instance of it. A store defined at the top level of that module is the bridge.
Setting Up Nano Stores
Nano Stores is a tiny, framework-agnostic state library (under 1 KB, no dependencies). Install the core package plus the helper for each framework you use:
npm install nanostores @nanostores/react @nanostores/vue
Svelte doesn't need a helper, because Nano Stores already follow Svelte's store contract. Preact and Solid have @nanostores/preact and @nanostores/solid.
At the time of writing the versions were nanostores 1.5.5, @nanostores/react 2.0.1 and @nanostores/vue 1.1.0.
Creating a Shared Store
Create a module for the store. It's ordinary TypeScript with no framework imports:
// src/stores/cart.ts
import { atom, map, computed } from 'nanostores';
export type CartItem = {
id: string;
name: string;
quantity: number;
};
export const isCartOpen = atom(false);
export const cartItems = map<Record<string, CartItem>>({});
export const cartCount = computed(cartItems, (items) =>
Object.values(items).reduce((total, item) => total + item.quantity, 0),
);
export function addCartItem({ id, name }: Pick<CartItem, 'id' | 'name'>) {
const existing = cartItems.get()[id];
cartItems.setKey(id, {
id,
name,
quantity: existing ? existing.quantity + 1 : 1,
});
}
There are three kinds of store here:
- atom: holds a single value, such as a boolean, string or array. Read with
.get(), write with.set(). - map: holds an object and adds
.setKey()to update one key without replacing the whole object. - computed: derives a value from other stores and updates automatically when they change.
Keeping the actions, like addCartItem(), in the same module means every island updates the cart the same way.
Writing to the Store from React
A React island that adds items doesn't need to re-render when the cart changes, so it can import the store and call functions directly:
// src/components/AddToCart.tsx
import { addCartItem, isCartOpen } from '../stores/cart';
interface Props {
id: string;
name: string;
}
export default function AddToCart({ id, name }: Props) {
function handleClick() {
addCartItem({ id, name });
isCartOpen.set(true);
}
return (
<button type="button" onClick={handleClick}>
Add {name} to cart
</button>
);
}
When a React component needs to display store values, use the useStore() hook from @nanostores/react. It subscribes the component so it re-renders on every change:
import { useStore } from '@nanostores/react';
import { cartCount } from '../stores/cart';
export default function CartCount() {
const count = useStore(cartCount);
return <span>{count}</span>;
}
The rule of thumb: use useStore() for values you render, and .get() for values you only read inside event handlers.
Reading the Store from Vue
Vue's helper also exports useStore(), which returns a reactive ref:
<!-- src/components/CartBadge.vue -->
<script setup lang="ts">
import { useStore } from '@nanostores/vue';
import { cartCount, isCartOpen } from '../stores/cart';
const count = useStore(cartCount);
const open = useStore(isCartOpen);
</script>
<template>
<button type="button" :aria-expanded="open" @click="isCartOpen.set(!open)">
Cart ({{ count }})
</button>
</template>
Reading the Store from Svelte
In Svelte, prefix the store with $ and it subscribes automatically:
<!-- src/components/CartFlyout.svelte -->
<script lang="ts">
import { cartItems, isCartOpen } from '../stores/cart';
</script>
{#if $isCartOpen}
<aside>
<h2>Your cart</h2>
<ul>
{#each Object.values($cartItems) as item (item.id)}
<li>{item.name} x {item.quantity}</li>
{:else}
<li>Your cart is empty.</li>
{/each}
</ul>
</aside>
{/if}
Reading the Store from an Astro Script
Plain <script> tags in Astro components are bundled modules, so they can import the same store and use .subscribe():
<!-- src/components/CartLog.astro -->
<p>Items in cart: <output id="cart-log">0</output></p>
<script>
import { cartCount } from '../stores/cart';
const output = document.getElementById('cart-log');
cartCount.subscribe((count) => {
if (output) output.textContent = String(count);
});
</script>
.subscribe() calls the function immediately with the current value and again on every change. If you only want future changes, use .listen() instead.
Putting It Together
Here's a page that uses all four, with three different frameworks plus an Astro component:
---
// src/pages/shop.astro
import AddToCart from '../components/AddToCart';
import CartBadge from '../components/CartBadge.vue';
import CartFlyout from '../components/CartFlyout.svelte';
import CartLog from '../components/CartLog.astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Shop</title>
</head>
<body>
<header>
<CartBadge client:load />
</header>
<main>
<AddToCart id="mug" name="Mug" client:load />
<AddToCart id="tee" name="T-shirt" client:load />
<CartLog />
</main>
<CartFlyout client:load />
</body>
</html>
I built this with Astro 7.4.2, opened it in Chrome, and clicked "Add Mug" twice and "Add T-shirt" once. The page text afterwards was:
Cart (3)
Add Mug to cartAdd T-shirt to cart
Items in cart: 3
Your cart
Mug x 2
T-shirt x 1
The two button labels run together because Astro 7's default compressHTML: 'jsx' strips the whitespace between the islands; add {" "} between them if you need a space. More importantly, the Vue badge, the Svelte flyout and the plain script all updated from clicks in React islands. If your project only uses Astro components, the same store pattern works with no framework at all, which is a nice way to keep the JavaScript for simple pages tiny.
Persisting State Across Page Loads
A Nano Store lives in memory, so it resets on every full page load. To keep values in localStorage and sync them between tabs, use @nanostores/persistent:
npm install @nanostores/persistent
// src/stores/prefs.ts
import { persistentAtom } from '@nanostores/persistent';
export const savedItems = persistentAtom<string[]>('saved-items', [], {
encode: JSON.stringify,
decode: JSON.parse,
});
persistentAtom() stores strings by default, so non-string values need the encode and decode options. The library also has persistentMap() and persistentBoolean() helpers.
Watch Out for Hydration Mismatches
Persisted state creates a subtle problem. On the server there is no localStorage, so the island renders with the default value. In the browser, the store starts with the saved value. I tested a React save button with client:load: after saving an item and reloading, the button correctly showed "Saved (1)", but the console reported:
pageerror: Minified React error #418; visit https://react.dev/errors/418?args[]=text&args[]= ...
That's React's hydration mismatch error: the server HTML said "Save (0)" and the client disagreed. React recovered, but the content flickered. Two clean ways to avoid it:
- Use
client:onlyfor components whose output depends on persisted state, so there's no server HTML to mismatch. See how to use client:only. - Render a neutral state first: show something that doesn't depend on the stored value, then update it in an effect after hydration.
Other Ways to Share State
Nano Stores aren't the only option. Depending on your project, these also work:
| Approach | Works across frameworks | Notes |
|---|---|---|
| Nano Stores | Yes | Recommended by Astro, tiny, has helpers for each framework |
Svelte writable() stores | Svelte only | No extra dependency if every island is Svelte |
Vue reactive() in a shared module | Vue only | Uses Vue's own reactivity system |
| Solid signals in a shared module | Solid only | Export a createSignal() from a file |
| Custom DOM events | Yes | window.dispatchEvent(new CustomEvent('cart:add', { detail })), no library, but no stored value |
Custom events are worth a mention for very simple cases, such as "close all popovers". They're fire-and-forget, though: an island that hydrates late (with client:visible, for example) misses events sent before it existed, while a store always holds the current value.
Pitfalls to Avoid
Writing to a Store in Frontmatter
Code in an Astro component's frontmatter runs on the server. Calling cartItems.set() there won't affect what islands see in the browser, because the browser loads a fresh copy of the module. To give islands initial data from the server, pass it as props, or read it from the HTML as shown in passing data from Astro to client-side scripts.
Shared Server State Between Requests
On an on-demand rendered site, a store module is loaded once by the server process and reused for every request. If you write user-specific data into a store during server rendering, the next visitor could see it. Treat stores as client-side state and keep per-request data in props or Astro.locals.
Passing Stores as Props
You can't pass a store object as a prop to a hydrated component, because it isn't serialisable. Import the store inside the component instead.
FAQ: Sharing State Between Astro Islands
Each island is hydrated as its own React root. A provider in an Astro file is rendered to static HTML on the server and never runs in the browser, so islands have no shared provider above them.
No. One Nano Store can be read and written by React, Vue, Svelte, Preact, Solid and plain scripts at the same time. You only need a small helper package per framework to subscribe from components.
Very little. The core library is under 1 KB with no dependencies, and the framework helpers are similarly small. The framework runtimes themselves are far larger.
Not with a full page load, because the browser starts a fresh JavaScript context. Use @nanostores/persistent to keep values in localStorage, or use Astro's ClientRouter with transition:persist to keep specific islands alive across navigations.
You can use any store that lives in a shared module, but framework-specific stores only work for islands in that framework. Nano Stores is recommended because it works across all of them.
You can import it, but you'll only get the initial value on the server, and Astro components don't re-render when it changes. Use stores for client-side state only.
Conclusion
Islands in Astro are deliberately isolated, which is what makes them cheap to hydrate one at a time. To connect them, move shared state out of components and into a module-level store. Nano Stores makes this straightforward: define atoms, maps and computed stores once, write to them from any island, and read them with useStore() in React and Vue, the $ prefix in Svelte, or .subscribe() in a plain script.
Keep stores on the client, pass server data in as props, and use @nanostores/persistent when state should survive a reload, with client:only or a neutral first render to avoid hydration mismatches. With that pattern, islands written in different frameworks can behave like one coherent interface while each stays small and independent.


