Field Notes

Zustand

Leaf, Branch, Tree: Scaling Zustand Client State to 75K Lines

MemeWin's Zustand store has 32 focused slices, eight branch aggregators, and one root store. Immer handles mutable-looking updates and reselect handles memoized selectors. This structure kept a 75K-line TypeScript codebase manageable.

Topic
Zustand
Year
Read
5 min

The problem

The store is arranged in three levels:

LEAF SLICES (32 files, atomic, single domain)

  • network.slice.ts stores chain IDs, RPC URLs, and block explorers
  • token.slice.ts stores balances, prices, and conversion rates with a two-minute TTL
  • game.slice.ts owns the provider list, categories, filters, and favourites
  • chat.slice.ts keeps up to 100 messages per room in FIFO order, plus the mute list
  • transaction.slice.ts tracks pending and confirmed deposits and withdrawals
  • There are 27 more slices, each under 100 lines

BRANCH AGGREGATORS (8 branches, compose leaves)

  • blockchain/ combines the network, token, and wallet slices
  • game/ combines game, category, provider, and filter slices
  • uiDefinition/ holds theme, layout, sidebar, and breakpoint state
  • query/ holds active filters, search state, and pagination cursors
  • transactions/ holds deposit, withdrawal, and swap history
  • affiliate/ holds the referral tree, commission rates, and earnings
  • bonus/ holds active bonuses, wagering progress, and claims
  • history/ holds bet history, pagination, and filters

ROOT STORE (single createStore())

  • It merges eight branches with createStore()((...a) => ({ ...a[0], ...a[1], ... }))
  • transactionStore stays separate because paginated history has a different lifecycle

The game-discovery selectors use reselect and take about 360 lines:

selectFilteredGames, then selectFilteredAndSortedGames, then selectVisibleGames

  • A search for lottery also considers bingo, keno, and lotto
  • Each selector is memoized, so a filter change only recomputes the affected chain
  • The GameCatalog component reads selectVisibleGames and avoids unnecessary renders

IMMER MUTABILITY

  • All slice reducers use produce((draft) => { draft.field = value })
  • That keeps updates readable without giving up Immer's immutable result

WHY NOT RTK QUERY / REDUX TOOLKIT?

  • RTK Query includes cache invalidation, but this product needed custom WebSocket invalidation for 33 event types and targeted query keys
  • Redux adds roughly 3KB and extra ceremony. Zustand, Immer, and reselect add roughly 2KB for this use case.
  • The setter, worker, and guard pattern keeps the equivalent flow explicit and easy to inspect

Result

Continue conversation

Frontend problem worth thinking through?

Bring the context, including the hard parts. I am happy to talk them through.