Shared counter
Separate bundle, same count
Share live updates between islands with DOM CustomEvents — without module-level stores that can leak on the server.
Each island is an independent component tree. React Context, Vue provide/inject, and Svelte stores inside one island do not reach another island on the same page. Avalon does not ship a global reactive store.
For live coordination between islands on the client, use the DOM event bus: CustomEvent + document.dispatchEvent / addEventListener. Listeners run only in the browser after hydration, so nothing from that channel runs during SSR.
In SPA-only apps, module-level singletons (Nanostores, an exported Zustand store, a shared Svelte writable at file scope) are a common way to share state. In islands + SSR setups, that pattern is a poor fit for live cross-island updates:
typeof window !== 'undefined'.CustomEvents match that model: event-driven, works across frameworks on one page, and keeps server rendering free of client-only coordination state.
Pick a stable, namespaced event name (e.g. cart:updated) and pass a JSON-serializable detail payload:
// app/modules/main/lib/cart-events.ts
export const CART_UPDATED = 'cart:updated';
export type CartUpdatedDetail = { count: number };
export function dispatchCartUpdated(count: number) {
document.dispatchEvent(
new CustomEvent(CART_UPDATED, { detail: { count }, bubbles: true }),
);
}
Add button (Preact island):
// app/modules/main/components/CartAddButton.tsx
import { useState } from 'preact/hooks';
import { dispatchCartUpdated } from '../lib/cart-events.ts';
export default function CartAddButton() {
const [count, setCount] = useState(0);
function add() {
const next = count + 1;
setCount(next);
dispatchCartUpdated(next);
}
return <button type="button" onClick={add}>Add to cart</button>;
}
Badge (separate island, can be another framework file):
// app/modules/main/components/CartBadge.tsx
import { useEffect, useState } from 'preact/hooks';
import { CART_UPDATED, type CartUpdatedDetail } from '../lib/cart-events.ts';
export default function CartBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
const onCart = (event: Event) => {
const detail = (event as CustomEvent<CartUpdatedDetail>).detail;
setCount(detail.count);
};
document.addEventListener(CART_UPDATED, onCart);
return () => document.removeEventListener(CART_UPDATED, onCart);
}, []);
return <span aria-live="polite">Cart: {count}</span>;
}
Page — direct imports, separate hydration if you want:
import CartAddButton from '../components/CartAddButton.tsx';
import CartBadge from '../components/CartBadge.tsx';
export default function ShopPage() {
return (
<header>
<CartBadge island={{ condition: 'on:client' }} />
<CartAddButton island={{ condition: 'on:interaction' }} />
</header>
);
}
The badge can hydrate immediately; the button can wait for interaction. Both stay in sync via cart:updated after the user clicks.
on:event is for hydration, not stateisland={{ condition: 'on:event', conditionArg: 'cart:updated' }} tells Avalon to hydrate when that event fires. It does not replace subscribing with addEventListener for ongoing updates. Use on:event to defer loading an island; use CustomEvents to broadcast state to already-mounted islands.
| Need | Approach |
|---|---|
| State inside one island, survive reload | usePersistentState (sessionStorage / localStorage) |
| Shareable URL, back/forward | Query params or history.pushState |
| Keep live instance across client navigations | island={{ persist: 'key' }} or data-router-persist |
| Request-scoped server data | Middleware context — not for client islands |
See also Built-in Components for persistence helpers and Client Navigation for persist on the router.
Two islands (Preact and Svelte) share one count via avalon-docs:shared-counter on document. No module-level store — only dispatchEvent and addEventListener.
Live — two islands, one count
Separate bundle, same count
Separate bundle, same count
Use + or − on either card; the other island updates after hydration.