Fresh logo

Sharing state between islands

All of this content is lifted from this great example by Luca. The source can be found here.

Multiple Sibling Islands with Independent State

Imagine we have Counter.tsx like this:

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…

<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:

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…

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.

Independent Islands

We can also create a signal in a utility file and export it for consumption across multiple places.

utils/cart.ts
import { signal } from "@preact/signals";

export const cart = signal<string[]>([]);
islands/AddToCart.tsx
import { Button } from "../components/Button.tsx";
import { cart } from "../utils/cart.ts";

interface AddToCartProps {
  product: string;
}

// This island is used to add a product to the cart state.
export default function AddToCart(props: AddToCartProps) {
  return (
    <Button
      onClick={() => (cart.value = [...cart.value, props.product])}
      class="w-full"
    >
      Add{cart.value.includes(props.product) ? " another" : ""} "{props.product}
      " to cart
    </Button>
  );
}
islands/Cart.tsx
import { Button } from "../components/Button.tsx";
import { cart } from "../utils/cart.ts";
import * as icons from "../components/Icons.tsx";

// This island is used to display the cart contents and remove items from it.
export default function Cart() {
  return (
    <h1 class="text-xl flex items-center justify-center">
      Cart
    </h1>

    <ul class="w-full bg-gray-50 mt-2 p-2 rounded 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 product={product} index={index} />
      ))}
    </ul>
  );
}

interface CartItemProps {
  product: string;
  index: number;
}

function CartItem(props: CartItemProps) {
  const remove = () => {
    const newCart = [...cart.value];
    newCart.splice(props.index, 1);
    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>
  );
}

Now we can add the islands to our site by doing the following:

<AddToCart product="Lemon" />
<AddToCart product="Lime" />
<Cart />

What happens as a result? The cart signal is shared across the two AddToCart islands and the Cart island.