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(); // Main store — network only
const { build, simulate } = useBuildFlowStore(); // Flow store — tx state
};
Main Store Pattern
Middleware stack
create<Store>()(
querystring(
immer((set) => ({ ... })),
{
url: options.url,
select() { /* what to persist in URL */ },
}
)
);
Hydration
Synchronous — URL is passed at creation time in StoreProvider:
// src/store/StoreProvider.tsx
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); // selector form
Transaction Flow Store Pattern
Middleware stack
create<TransactionFlowStore>()(
persist(
immer((set) => ({ ... })),
{
name: "stellar_lab_tx_flow_build",
storage: createJSONStorage(() => sessionStorage),
skipHydration: true, // CRITICAL
}
)
);
Hydration — The Critical Gotcha
Never rehydrate in useEffect. Child effects fire before parent effects and
will write default state to sessionStorage, overwriting persisted data.
// WRONG — don't do this
useEffect(() => {
useBuildFlowStore.persist.rehydrate();
}, []);
// CORRECT — module-scope rehydration (runs before any component mounts)
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 statestellar_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_STATEwith 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:
activeStepandhighestCompletedStepare 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 |