---
title: "Share state with the store"
audience: "app developers coming from Solid, Preact signals, Svelte runes or Leptos"
description: "You'll build an order page whose islands share state the way signals would: a badge in the header and a stepper on the page read one value, a picker writes…"
snippet_caption: "app/src/ui/Sizes.tsx"
snippet: "import { set, transaction } from \"@snapfire/fsr-client/store\";\nimport { useStore } from \"@snapfire/fsr-client/react\";\n\nimport { price, size } from \"@src/store\";\n\nexport default function Sizes({ sizes }: { sizes: { label: string; price: number }[] }) {\n  const [chosen] = useStore(size, \"250g\");\n  return (\n    <div className=\"sizes\">\n      {sizes.map((s) => (\n        <button\n          key={s.label}\n          type=\"button\"\n          aria-pressed={s.label === chosen}\n          onClick={() =>\n            transaction(() => {\n              set(size, s.label);\n              set(price, s.price);\n            })\n          }\n        >\n          {s.label}\n        </button>\n      ))}\n    </div>\n  );\n}"
---

import { Island } from "@snapfire/fsr-client/react";
import { Install } from "@src/ui/Install";
import { Demo } from "@src/ui/Demo";
import Badge from "@src/demos/order/Badge";
import Sizes from "@src/demos/order/Sizes";
import Stepper from "@src/demos/order/Stepper";
import Total from "@src/demos/order/Total";

You'll build an order page whose islands share state the way signals would: a badge in the header and a stepper on the page read one value, a picker writes two values at once and a write that the server refuses is taken back. Shared state is a key in the store and state one island owns is React's `useState`.

<Install captured="0.x" />

```shell-session
$ fsr new shop --with react
```

| With signals | In FSR |
|---|---|
| a signal shared between components | a store key, read with `useStore` |
| `computed` | `derive` or a calculation in the component |
| `effect` | `subscribe` |
| `batch` | `transaction` |
| a signal one component owns | `useState` in React, `ref` in Vue |

## Declare the keys

A key is a name with a type. Declare the keys in one module so every island imports the same ones:

```ts title="app/src/store.ts"
import { key } from "@snapfire/fsr-client/store";

export const qty = key<number>("order/qty");
export const price = key<number>("order/price");
```

The build reads the string inside `key<number>(...)`, so a key declared here and used in another file still lowers.

## Seed the quantity from the session

The quantity is the server's: it lives in the session and an action changes it.

```ts title="app/schemas/session.ts"
export interface Session {
  qty: bigint;
}

export const defaults: Session = {
  qty: 0n,
};
```

```ts title="app/schemas/order.ts"
export interface SetQty {
  qty: bigint;
}
```

A loader's `store` export seeds keys from what the loader returned. Put it on the root layout so every page starts with the quantity:

```ts title="app/routes/layout.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load({ session }: Ctx) {
  return { qty: session.qty };
}

export const store = ({ data }: { data: { qty: bigint } }) => ({ "order/qty": Number(data.qty) });
```

The action refuses anything past five:

```ts title="app/routes/order/actions.ts"
import { action, fail } from "@snapfire/fsr";
import type { ActionCtx } from "@snapfire/fsr";
import type { SetQty } from "@schemas/order";

export const setQty = action(async ({ input, session }: ActionCtx<SetQty>) => {
  if (input.qty < 0n) fail("invalid", "no negative orders");
  if (input.qty > 5n) fail("invalid", "at most 5 per order");
  session.qty = input.qty;
  return { qty: session.qty };
});
```

## Read one key from two islands

A badge in the header:

```tsx title="app/src/ui/Badge.tsx"
import { useStore } from "@snapfire/fsr-client/react";

import { qty } from "@src/store";

export default function Badge() {
  const [n] = useStore(qty, 0);
  return <span className="badge">{n} in your order</span>;
}
```

```tsx title="app/routes/layout.tsx"
import type { ReactNode } from "react";
import { Island, Link } from "@snapfire/fsr-client/react";
import Badge from "@src/ui/Badge";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          shop
        </Link>
        <Island when="load">
          <Badge />
        </Island>
      </header>
      <main className="content">{children}</main>
    </div>
  );
}
```

A stepper on the page reads the same key. `optimistic` shows the new value at once, runs the action and puts the old value back if the action fails:

```tsx title="app/src/ui/Stepper.tsx"
import { useState } from "react";
import { optimistic } from "@snapfire/fsr-client";
import { useStore } from "@snapfire/fsr-client/react";

import { actions } from "@generated/client";
import { qty } from "@src/store";

export default function Stepper() {
  const [n] = useStore(qty, 0);
  const [error, setError] = useState("");

  async function change(next: number): Promise<void> {
    setError("");
    try {
      await optimistic(qty, next, () => actions.order.setQty({ qty: BigInt(next) }));
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  return (
    <div className="stepper">
      <button type="button" onClick={() => void change(n - 1)}>-</button>
      <output>{n}</output>
      <button type="button" onClick={() => void change(n + 1)}>+</button>
      {error ? <p className="error">{error}</p> : null}
    </div>
  );
}
```

The error message belongs to this stepper and nothing else reads it, so it is `useState`, not a key.

The `/order` page's loader returns the product and its sizes and seeds the price:

```ts title="app/routes/order/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load(_ctx: Ctx<"/order">) {
  return {
    name: "House blend",
    sizes: [
      { label: "250g", price: 12 },
      { label: "1kg", price: 40 },
    ],
  };
}

export const store = () => ({ "order/price": 12 });
```

```tsx title="app/routes/order/page.tsx"
import type { OrderProps } from "@generated/client";
import { Island } from "@snapfire/fsr-client/react";
import Stepper from "@src/ui/Stepper";

export default function Order({ name }: OrderProps) {
  return (
    <section>
      <h1>{name}</h1>
      <Island when="load">
        <Stepper />
      </Island>
    </section>
  );
}
```

Click `+` and the badge in the header moves with the stepper. Neither island imports the other. Click it a sixth time and both show 6, then go back to 5 when the action answers `400` and the stepper shows "at most 5 per order".

When an action succeeds the route revalidates and the layout loader seeds `order/qty` again from the session. The server's value replaces whatever the browser wrote.

## Compute a total

The signals habit is a computed value. `derive` is the store's:

```ts title="app/src/store.ts"
import { derive, key } from "@snapfire/fsr-client/store";

export const qty = key<number>("order/qty");
export const price = key<number>("order/price");
export const total = key<number>("order/total");

derive(total, [price, qty], (read) => (read(price) ?? 0) * (read(qty) ?? 0));
```

An island that reads `total` with `useStore(total, 0)`, placed under the stepper, shows the right number in the browser. Now ask the server for the page with five in the order, passing the session cookie your browser holds:

```shell-session
$ curl -s -H "cookie: $SESSION" localhost:3000/order | grep total
<p class="total">Total: $<!-- -->0</p>
```

`derive` runs in the browser only. The server has no value for `order/total`, so it renders the island from the fallback and the total jumps from $0 to $60 when the script runs. A value the first paint needs is computed in the component from the keys it reads:

```tsx title="app/src/ui/Total.tsx"
import { useStore } from "@snapfire/fsr-client/react";

import { price, qty } from "@src/store";

export default function Total() {
  const [p] = useStore(price, 0);
  const [n] = useStore(qty, 0);
  return <p className="total">Total: ${p * n}</p>;
}
```

```shell-session
$ curl -s -H "cookie: $SESSION" localhost:3000/order | grep total
<p class="total">Total: $<!-- -->60</p>
```

Keep `derive` for values nothing renders on the first paint.

## Write two keys at once

Add a size picker. Choosing a size changes the size and the price together, so they go in one `transaction`:

```tsx title="app/src/ui/Sizes.tsx"
import { set, transaction } from "@snapfire/fsr-client/store";
import { useStore } from "@snapfire/fsr-client/react";

import { price, size } from "@src/store";

export default function Sizes({ sizes }: { sizes: { label: string; price: number }[] }) {
  const [chosen] = useStore(size, "250g");
  return (
    <div className="sizes">
      {sizes.map((s) => (
        <button
          key={s.label}
          type="button"
          aria-pressed={s.label === chosen}
          onClick={() =>
            transaction(() => {
              set(size, s.label);
              set(price, s.price);
            })
          }
        >
          {s.label}
        </button>
      ))}
    </div>
  );
}
```

To see what the transaction buys, derive a line from both keys and log it with `subscribe`, the store's `effect`:

```ts title="app/src/store.ts"
import { derive, key, subscribe } from "@snapfire/fsr-client/store";

export const qty = key<number>("order/qty");
export const price = key<number>("order/price");
export const size = key<string>("order/size");
export const line = key<string>("order/line");

derive(line, [size, price], (read) => `${read(size) ?? "250g"} at $${read(price) ?? 0}`);
subscribe(line, (value) => console.log("line", value));
```

Switch from 250g to 1kg and the console logs one line:

```shell-session
line 1kg at $40
```

Take the `transaction` out and the same click logs two, the first of them a size with the wrong price:

```shell-session
line 1kg at $12
line 1kg at $40
```

A listener hears each key once per transaction, after every write in it has landed.

## Keep browser choices out of the seed

Pick 1kg, then click `+`. The size goes back to 250g and the total uses $12 again.

The action succeeded, so the route revalidated and the page loader seeded `order/price` again. A seed is the server's value and it replaces what an island wrote. The size is a choice the browser makes and the server never hears about, so the loader shouldn't seed it. Drop the `store` export from the page loader:

```ts title="app/routes/order/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";

export async function load(_ctx: Ctx<"/order">) {
  return {
    name: "House blend",
    sizes: [
      { label: "250g", price: 12 },
      { label: "1kg", price: 40 },
    ],
  };
}
```

and give the total its starting price as a prop, which is what `useStore` falls back to while the key is unset:

```tsx title="app/src/ui/Total.tsx"
import { useStore } from "@snapfire/fsr-client/react";

import { price, qty } from "@src/store";

export default function Total({ start }: { start: number }) {
  const [p] = useStore(price, start);
  const [n] = useStore(qty, 0);
  return <p className="total">Total: ${p * n}</p>;
}
```

```tsx title="app/routes/order/page.tsx"
import type { OrderProps } from "@generated/client";
import { Island } from "@snapfire/fsr-client/react";
import Sizes from "@src/ui/Sizes";
import Stepper from "@src/ui/Stepper";
import Total from "@src/ui/Total";

export default function Order({ name, sizes }: OrderProps) {
  return (
    <section>
      <h1>{name}</h1>
      <Island when="load">
        <Sizes sizes={sizes} />
      </Island>
      <Island when="load">
        <Stepper />
      </Island>
      <Island when="load">
        <Total start={sizes[0].price} />
      </Island>
    </section>
  );
}
```

Now 1kg survives the `+`: three bags read $120. Seed what the server owns. Start what the browser owns from a prop.

<Demo title="The badge and the order page from this tutorial, four islands on one store">
  <Island when="load">
    <Badge />
  </Island>
  <Island when="load">
    <Sizes sizes={[{ label: "250g", price: 12 }, { label: "1kg", price: 40 }]} />
  </Island>
  <Island when="load">
    <Stepper />
  </Island>
  <Island when="load">
    <Total start={12} />
  </Island>
</Demo>

The quantity here is in your session, so it survives a reload. Pick 1kg and step past five to watch the stepper take the sixth back.

## Give each island its own state

Signals are values you pass around, so each instance of a component can hold its own. A store key is a global name. You might try building one name per instance:

```tsx title="app/src/ui/Gift.tsx"
import { key } from "@snapfire/fsr-client/store";
import { useStore } from "@snapfire/fsr-client/react";

export default function Gift({ id }: { id: string }) {
  const [wrapped, setWrapped] = useStore(key<boolean>(`gift/${id}`), false);
  return (
    <label className="gift">
      <input type="checkbox" checked={wrapped} onChange={() => setWrapped(!wrapped)} /> Gift wrap
    </label>
  );
}
```

Place it on the page as `<Gift id="house-blend" />` inside an `Island` and build. The build can't read that name, so the island leaves the server:

```shell-session
$ fsr build app
client    src/ui/Gift.tsx:5:42               a `useStore` key that is not a string the build can read
          1 module renders in the browser for it
            src/ui/Gift.tsx#default          <Gift> routes/order/page.tsx:19:9
```

State that belongs to one instance is `useState`:

```tsx title="app/src/ui/Gift.tsx"
import { useState } from "react";

export default function Gift() {
  const [wrapped, setWrapped] = useState(false);
  return (
    <label className="gift">
      <input type="checkbox" checked={wrapped} onChange={() => setWrapped(!wrapped)} /> Gift wrap
    </label>
  );
}
```

Place it as `<Gift />` and it lowers, so the server renders it again. In a Vue island the same split holds: `ref` and `computed` for what the island owns, the Vue `useStore` for keys it shares. [220](https://snapfirers.com/fsr/learn/220-react-and-vue-on-one-page) shares one key between a React island and a Vue one.

An update re-renders the components that read the key, through React or Vue. Nothing patches a single text node the way Solid or Preact signals do.

Next up: [300. Run one app on two domains](https://snapfirers.com/fsr/learn/300-run-one-app-on-two-domains).
