Passing Signals Between Fresh Islands
Two islands on the same page frequently need the same number: a basket count in the header and an add button in the product area. In Fresh the mechanism for that is a signal created in the route and passed to both, which avoids the external store that sharing state across islands describes for other frameworks. This walkthrough covers what actually crosses the boundary, the one case that surprises everyone, and how to verify the sharing works.
Prerequisites
Implementation Steps
Step 1 — Create the signal where both islands can see it
The route component is the only place both islands exist as siblings, so the signal belongs there. Seed it from the loader’s data so the first paint is correct rather than a default.
// routes/product/[id].tsx
import { signal } from "@preact/signals";
import BasketBadge from "../../islands/BasketBadge.tsx";
import AddToBasket from "../../islands/AddToBasket.tsx";
export default function ProductPage({ data }: PageProps) {
// Seeded from server data: the badge renders "3", not "0", before any JS runs.
const basketCount = signal(data.basketCount);
return (
<>
<header>
<BasketBadge count={basketCount} />
</header>
<main>
<h1>{data.product.title}</h1>
{/* Same signal instance — the runtime records that these two props
refer to one value and reconnects them during activation. */}
<AddToBasket productId={data.product.id} count={basketCount} />
</main>
</>
);
}
Step 2 — Read and write it directly in each island
// islands/AddToBasket.tsx
import type { Signal } from "@preact/signals";
export default function AddToBasket(
{ productId, count }: { productId: string; count: Signal<number> },
) {
async function add() {
count.value += 1; // optimistic; the badge updates now
const res = await fetch(`/api/basket/${productId}`, { method: "POST" });
if (!res.ok) count.value -= 1; // roll back, badge follows again
}
return <button type="button" onClick={add}>Add to basket</button>;
}
The badge island needs no knowledge of this file. It renders count.value and re-renders when the value changes, which is the whole contract.
Step 3 — Keep derivations on the client side of the boundary
A computed value defined in the route arrives as a number and stays that number forever. Define it in the island instead, from signals the route passed in:
// islands/BasketTotal.tsx — derivation lives in island code, so it re-runs.
import { computed, type Signal } from "@preact/signals";
export default function BasketTotal(
{ count, unitPrice }: { count: Signal<number>; unitPrice: Signal<number> },
) {
const total = computed(() => count.value * unitPrice.value);
return <output>{total.value.toFixed(2)}</output>;
}
Verification
Cross-island propagation. Click the add button and confirm the header badge changes in the same frame. If it lags or never updates, the two islands received separate signals — usually because the route created the signal inside a child component rather than in the route itself.
One serialised value. Search the document source for the value. It should appear once in the serialised payload. Appearing once per island means the runtime treated them as unrelated values, and the sharing is an illusion that will break the first time one of them writes.
Correct first paint. Load with JavaScript disabled and confirm the badge shows the server value. A zero here means the signal was created with a default and only corrected after activation, which is a visible flash for every visitor on a slow connection.
When a Signal Is the Wrong Tool
Signals passed through a route solve one specific problem: two islands rendered by the same route render pass, needing the same value, on the same page load. Three situations fall outside that and need something else.
State that must outlive the page. A basket that survives a navigation cannot live in a signal created per render — the next route creates a new one. Persist it server-side and seed the signal from the loader on every route, which keeps the fast in-page sharing while making the server the source of truth. A visitor who opens a second tab then sees a consistent number, which a purely client-side signal cannot provide.
State written by something that is not an island. A third-party widget, a service worker message, or a native app shell posting into the page has no route reference to receive. Give those an event channel or a small store module both the island and the outside code import, as described in implementing global event buses for island communication.
Values that are expensive to serialise. The signal’s value ships inside the document for every visitor, so a large object shared between two islands is paid for by everyone including visitors who never activate either island. Pass an identifier and let the island fetch the detail after activation; the sharing still works because both islands read the same small signal.
The general rule is that a signal is page-scoped shared state with excellent ergonomics and no persistence. Reach past it the moment the requirement includes the words “after navigation” or “in another tab”.
Troubleshooting
Why does my computed signal never update on the client?
Because it was defined in the route. Only the current value of a computed signal is serialised; the function that derives it stays on the server, so the client receives a frozen number rather than a live derivation. Pass the input signals to the island instead and define the computed value inside island code, where the derivation function is part of the module that ships and can re-run whenever its inputs change.
Do both islands need to import the same module for this to work?
No, and that is the appealing part of the model. The route holds the reference and passes it to each island as a prop, so neither island imports the other or a shared store module. The runtime reconnects them during activation because the serialised payload records that both props refer to the same signal. The limit is scope: this works within one page render, not across navigations.
What happens if only one of the two islands has activated?
A write from the active island updates the signal immediately, and the other island picks up the current value when it activates — its first render reads the signal rather than the serialised prop. The visible effect is that a badge below the fold shows the correct number the moment it comes to life, with no flash of the stale server value, provided the island renders from the signal rather than from a separately captured prop.
Related
- Converting a Preact Component into a Fresh Island — how a component becomes an island in the first place.
- Sharing State Across Islands — the external-store approach other frameworks require.
- Cross-Boundary Prop Passing Patterns — what serialises and what cannot, in general.
← Back to Fresh and Deno Islands