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.tsstores chain IDs, RPC URLs, and block explorerstoken.slice.tsstores balances, prices, and conversion rates with a two-minute TTLgame.slice.tsowns the provider list, categories, filters, and favouriteschat.slice.tskeeps up to 100 messages per room in FIFO order, plus the mute listtransaction.slice.tstracks 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 slicesgame/combines game, category, provider, and filter slicesuiDefinition/holds theme, layout, sidebar, and breakpoint statequery/holds active filters, search state, and pagination cursorstransactions/holds deposit, withdrawal, and swap historyaffiliate/holds the referral tree, commission rates, and earningsbonus/holds active bonuses, wagering progress, and claimshistory/holds bet history, pagination, and filters
ROOT STORE (single createStore())
- It merges eight branches with
createStore()((...a) => ({ ...a[0], ...a[1], ... })) transactionStorestays 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
lotteryalso considersbingo,keno, andlotto - Each selector is memoized, so a filter change only recomputes the affected chain
- The GameCatalog component reads
selectVisibleGamesand 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