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.
import{action,fail}from"@snapfire/fsr";importtype{ActionCtx}from"@snapfire/fsr";importtype{SetQty}from"@schemas/order";exportconstsetQty=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};});
import{useStore}from"@snapfire/fsr-client/react";import{qty}from"@src/store";exportdefaultfunctionBadge(){const[n]=useStore(qty,0);return<spanclassName="badge">{n} in your order</span>;}
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.
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:
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:
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";exportconstqty=key<number>("order/qty");exportconstprice=key<number>("order/price");exportconstsize=key<string>("order/size");exportconstline=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 $12line 1kg at $40
A listener hears each key once per transaction, after every write in it has landed.
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:
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";exportdefaultfunctionGift({id}:{id:string}){const[wrapped,setWrapped]=useStore(key<boolean>(`gift/${id}`),false);return(<labelclassName="gift"><inputtype="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 appclient 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";exportdefaultfunctionGift(){const[wrapped,setWrapped]=useState(false);return(<labelclassName="gift"><inputtype="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.