Fresh logo

Sharing state between islands

Multiple Sibling Islands with Independent State

Imagine we have Counter.tsx like this:

Typescript islands/Counter.tsx
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…

Typescript routes/index.tsx
<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:

Typescript islands/SynchronizedSlider.tsx
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…

Typescript routes/index.tsx
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.

Typescript islands/AddToCart.tsx
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>
  );
}
Typescript islands/Cart.tsx
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:

Typescript routes/cart.tsx
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.