| name | zustand-store-patterns |
| description | Reference for the two Zustand store patterns in Stellar Lab — main store (querystring) and transaction flow store (sessionStorage). Invoke when creating store slices, debugging hydration, or deciding where state belongs. |
Zustand Store Patterns
Two Stores, Two Purposes
| Main Store | Transaction Flow Store |
|---|
| File | src/store/createStore.ts | src/store/createTransactionFlowStore.ts |
| Hook | useStore() | useBuildFlowStore() |
| Middleware | querystring(immer(...)) | persist(immer(...)) |
| Persists to | URL querystring | sessionStorage |
| Survives tab close | Yes (URL) | No |
| Survives refresh | Yes (URL) | Yes (sessionStorage) |
| Instance type | New per provider mount | Singleton (module-level) |
| Used by | Entire app (~85 files) | Transaction build flow (~21 files) |
When to Use Which
Main store (useStore): For state that is app-wide, URL-shareable, or used
outside the transaction flow. Network settings, XDR viewer state, endpoint
explorer state, account page state.
Flow store (useBuildFlowStore): For state within the single-page
transaction flow. Build params, operations, simulation results, signed XDR, step
navigation. This state is too large for URLs (simulation results can be 50KB+).
Cross-store reads: Components in the transaction flow read network from
the main store as a read-only dependency. Never duplicate network config into
the flow store.
export const SimulateStepContent = () => {
const { network } = useStore();
const { build, simulate } = useBuildFlowStore();
};
Main Store Pattern
Middleware stack
create<Store>()(
querystring(
immer((set) => ({ ... })),
{
url: options.url,
select() { },
}
)
);
Hydration
Synchronous — URL is passed at creation time in StoreProvider:
const [store] = useState(() => createStore({ url }));
No manual rehydration needed. Store re-creates on route change.
Consumption
Via React Context (because instance is dynamic):
import { useStore } from "@/store/useStore";
const { network } = useStore();
const network = useStore((s) => s.network);
Transaction Flow Store Pattern
Middleware stack
create<TransactionFlowStore>()(
persist(
immer((set) => ({ ... })),
{
name: "stellar_lab_tx_flow_build",
storage: createJSONStorage(() => sessionStorage),
skipHydration: true,
}
)
);
Hydration — The Critical Gotcha
Never rehydrate in useEffect. Child effects fire before parent effects and
will write default state to sessionStorage, overwriting persisted data.
useEffect(() => {
useBuildFlowStore.persist.rehydrate();
}, []);
if (typeof window !== "undefined") {
useBuildFlowStore.persist.rehydrate();
}
This runs at module load time, before React renders anything. The
skipHydration: true flag prevents Zustand's automatic rehydration (which
happens too late).
Consumption
Direct import (singleton, no context needed):
import { useBuildFlowStore } from "@/store/createTransactionFlowStore";
const { build, simulate, setActiveStep } = useBuildFlowStore();
Storage keys
stellar_lab_tx_flow_build — Build flow state
stellar_lab_tx_flow_import — Import flow state (independent)
Each flow has its own store instance and sessionStorage key. Navigating between
Build and Import tabs does not clear the other's state.
Adding New State
To the main store
- Add the field + action to
createStore.ts
- If it should persist in URL, add to the
select() config
- Consume via
useStore()
To the flow store
- Add the field to the appropriate slice (
build, simulate, sign,
validate, submit)
- Add setter action(s) using Immer's mutable syntax:
setSignedXdr: (xdr: string) =>
set((state) => {
state.sign.signedXdr = xdr;
}),
- Add to
INITIAL_STATE with a sensible default
- Add to reset logic if the field should clear when the user goes back to Build
- Consume via
useBuildFlowStore()
Data conventions
- XDR values: Always store as base64 strings, never SDK objects
- RPC results: Store as raw JSON strings (
JSON.stringify(result))
- Step navigation:
activeStep and highestCompletedStep are persisted —
they survive refresh
- Validation state: Can only raise
highestCompletedStep, never lower it
(except on explicit reset)
Error Handling for sessionStorage
All sessionStorage access must be wrapped in try-catch:
- Write failure (QuotaExceededError): Fall back to in-memory only
- Read failure (corrupted JSON): Reset to default state
- Private mode: sessionStorage works but clears on tab close (same behavior)
Key Files
| Purpose | File |
|---|
| Main store | src/store/createStore.ts |
| Main store hook | src/store/useStore.ts |
| Store provider | src/store/StoreProvider.tsx |
| Flow store | src/store/createTransactionFlowStore.ts |
| Root layout | src/app/layout.tsx |