# Zustand Store Patterns

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

- Skill: `stellar/zustand-store-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add stellar/zustand-store-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stellar/zustand-store-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: stellar (https://skillmd.com/u/stellar)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/stellar/zustand-store-patterns

---


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

```typescript
export const SimulateStepContent = () => {
  const { network } = useStore();              // Main store — network only
  const { build, simulate } = useBuildFlowStore(); // Flow store — tx state
};
```

## Main Store Pattern

### Middleware stack

```typescript
create<Store>()(
  querystring(
    immer((set) => ({ ... })),
    {
      url: options.url,
      select() { /* what to persist in URL */ },
    }
  )
);
```

### Hydration

Synchronous — URL is passed at creation time in `StoreProvider`:

```typescript
// 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):

```typescript
import { useStore } from "@/store/useStore";

const { network } = useStore();
const network = useStore((s) => s.network); // selector form
```

## Transaction Flow Store Pattern

### Middleware stack

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

```typescript
// 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):

```typescript
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

1. Add the field + action to `createStore.ts`
2. If it should persist in URL, add to the `select()` config
3. Consume via `useStore()`

### To the flow store

1. Add the field to the appropriate slice (`build`, `simulate`, `sign`,
   `validate`, `submit`)
2. Add setter action(s) using Immer's mutable syntax:
   ```typescript
   setSignedXdr: (xdr: string) =>
     set((state) => {
       state.sign.signedXdr = xdr;
     }),
   ```
3. Add to `INITIAL_STATE` with a sensible default
4. Add to reset logic if the field should clear when the user goes back to Build
5. 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` |

