Sharing state between islands
Multiple Sibling Islands with Independent State
Imagine we have Counter.tsx like this:
import { useSignal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
interface CounterProps {
start: number;
}
// This island is used to display a counter and increment/decrement it. The
// state for the counter is stored locally in this island.
export default function Counter(props: CounterProps) {
const count = useSignal(props.start);
return (
<div class="flex gap-2 items-center w-full">
<p class="flex-grow-1 font-bold text-xl">{count}</p>
<Button onClick={() => count.value--}>-1</Button>
<Button onClick={() => count.value++}>+1</Button>
</div>
);
}Note how useSignal is within the Counter component. Then if we instantiate
some counters like this…
<Counter start={3} />
<Counter start={4} />they’ll keep track of their own independent state. Not much sharing going on here, yet.
Multiple Sibling Islands with Shared State
But we can switch things up by looking at a SynchronizedSlider.tsx like this:
import { Signal } from "@preact/signals";
interface SliderProps {
slider: Signal<number>;
}
// This island displays a slider with a value equal to the `slider` signal's
// value. When the slider is moved, the `slider` signal is updated.
export default function SynchronizedSlider(props: SliderProps) {
return (
<input
class="w-full"
type="range"
min={1}
max={100}
value={props.slider.value}
onInput={(e) => (props.slider.value = Number(e.currentTarget.value))}
/>
);
}Now if we were to do the following…
export default function Home() {
const sliderSignal = useSignal(50);
return (
<div>
<SynchronizedSlider slider={sliderSignal} />
<SynchronizedSlider slider={sliderSignal} />
<SynchronizedSlider slider={sliderSignal} />
</div>
);
}they would all use the same value.
Sharing State Across Independent Islands
When islands are not rendered as siblings (e.g. one in a sidebar and one in the main content), you can share state by creating a signal in a parent component and passing it as a prop to each island.
import { type Signal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
interface AddToCartProps {
cart: Signal<string[]>;
product: string;
}
export default function AddToCart(props: AddToCartProps) {
const { cart, product } = props;
return (
<Button
onClick={() => (cart.value = [...cart.value, product])}
class="w-full"
>
Add{cart.value.includes(product) ? " another" : ""} "{product}" to cart
</Button>
);
}import { type Signal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
import * as icons from "../components/Icons.tsx";
interface CartProps {
cart: Signal<string[]>;
}
export default function Cart(props: CartProps) {
const { cart } = props;
return (
<div>
<h1 class="text-xl flex items-center justify-center">Cart</h1>
<ul class="w-full bg-gray-50 mt-2 p-2 rounded-sm min-h-[6.5rem]">
{cart.value.length === 0 && (
<li class="text-center my-4">
<div class="text-gray-400">
<icons.Cart class="w-8 h-8 inline-block" />
<div>Your cart is empty.</div>
</div>
</li>
)}
{cart.value.map((product, index) => (
<CartItem cart={cart} product={product} index={index} />
))}
</ul>
</div>
);
}
interface CartItemProps {
cart: Signal<string[]>;
product: string;
index: number;
}
function CartItem(props: CartItemProps) {
const remove = () => {
const newCart = [...props.cart.value];
newCart.splice(props.index, 1);
props.cart.value = newCart;
};
return (
<li class="flex items-center justify-between gap-1">
<icons.Lemon class="text-gray-500" />
<div class="flex-1">{props.product}</div>
<Button onClick={remove} aria-label="Remove" class="border-none">
<icons.X class="inline-block w-4 h-4" />
</Button>
</li>
);
}Then wire them together from a route, passing the same signal to both:
import { useSignal } from "@preact/signals";
import AddToCart from "../islands/AddToCart.tsx";
import Cart from "../islands/Cart.tsx";
import { define } from "../utils.ts";
export default define.page(function CartPage() {
const cart = useSignal<string[]>([]);
return (
<div>
<AddToCart cart={cart} product="Lemon" />
<AddToCart cart={cart} product="Lime" />
<Cart cart={cart} />
</div>
);
});The cart signal is created per-render (not at module level), so each request
gets its own independent cart. Fresh serializes
the signal and passes it to both islands, keeping them in sync on the client.
[!CAUTION] Avoid creating signals at the module level (e.g.
export const cart = signal([])in a utility file). Module-level state is shared across all requests on the server, which means different users would see the same cart. Always create signals inside components or handlers.