Tutorial 9 of 36 · for app developers coming from Solid, Preact signals, Svelte runes or Leptos · Markdown

Share state with the store

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

$ fsr new shop --with react
With signalsIn FSR
a signal shared between componentsa store key, read with useStore
computedderive or a calculation in the component
effectsubscribe
batchtransaction
a signal one component ownsuseState 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:

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.

app/schemas/session.ts
export interface Session {
  qty: bigint;
}

export const defaults: Session = {
  qty: 0n,
};
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:

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:

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:

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

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:

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 });
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:

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:

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

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>;
}
$ 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:

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:

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:

line 1kg at $40

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

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:

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:

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>;
}
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.

LiveThe badge and the order page from this tutorial, four islands on one store
0 in your order
0

Total: $0

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:

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:

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

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