Client state lives in thin Zustand 5 stores, one per domain, created once per app start over the data layer's ports; every change is reduced by a pure reducer, written to disk, and only then shown; server data stays in TanStack Query. Two zero-dependency scripts prove it on the app's own code.
Rules that must hold
- One store per domain, made by a factory and created once per app start.
create<Domain>Store(deps)builds a vanilla store (createStorefromzustand/vanilla) over the ports it receives;createStores(deps)calls each factory once (the root layout'suseStateinitializer, andtest/test-app.tsxover fakes),StoresProviderhands the set down, components readuse<Domain>Store(selector). Nocreatefromzustand, no store at module level, no factory call in feature or UI code; the one module-level store is an OS mirror listed incontracts/stores.json(accessibility-as-code owns it). A store another skill ships with its own provider (the settings store) is created once by its own opener and follows the same rules. Why: tests need fresh stores, and Fast Refresh re-runs an edited module while components keep their state, so a module-level store is made a second time; auseStateinitializer makes it once per React tree. - Persist before publish. A change asks the reducer first (a refused or no-op change returns the
same object: no write, no render), writes through the port the factory received (a repository, the
document service, a preferences port: synchronous, committed when it returns ok), checks the result,
and only then calls
set. A failed write publishes nothing but the failure (a returnedResult, or a…Failurefield). When persistence assigns the id, persist first and publish the saved row through the reducer. State a service produces (a Premium entitlement from the purchase flow) still enters through a store method that writes it (grantPremium(entitlement)), never a publish-onlydispatch. A store meant to forget at restart says// memory-only: <why>, never a store whose state is saved. Why: the screen never shows a value that is not on disk, and iOS and Android both end background apps without warning. - All logic sits in pure reducers with imperative kebab-case actions, pinned by a cases table.
<domain>Reducer(state, action)imports no runtime code, never mutates, never reads the clock or randomness (time and ids arrive in the action) and returns the same object when nothing changes; everysetpublishes a reducer result;<name>-reducer-cases.tsbeside it holds the rules as plain data (at least onesame: truecase), run by its test and bycheck-reducers. Why: pure reducers replay the same in Jest, Node and on both platforms, and a rule nobody pins drifts. - Selectors return a primitive or a reference already in the state; a built value goes through
useShallow; no store hook without a selector, no equality argument. Named selectors live in<domain>-selectors.ts; a builder (.filter,.map,{ … }) is read asuse<Domain>Store(useShallow(selectX)). Why: in Zustand 5 a selector returning a new reference each call makes React throw "Maximum update depth exceeded" (verified, RNTL 14 on React 19.2), and the v4 equality argument is gone. - A write that spans stores is one transaction, then every store re-reads.
updateAndPublish(stores, () => tasks.removeList(id)), with each such write named incontracts/stores.jsoncrossStoreWrites. Never several store calls, never the bare write. Why: a kill between two writes leaves half a change, and a bare write leaves other stores showing deleted rows. - Server state and navigation state never enter a store. Network data lives in the TanStack Query
cache (networking-and-server-state): no
fetch, API client or React Query in a store, no effect that copiesquery.datainto one; route params belong to Expo Router. Stores hold client state: local rows, UI choices, preferences, sessions. Why: two copies of server data drift on every refetch, and a copied route goes stale on back (Android predictive back, the iOS swipe). - Stores are synchronous and persist only through the data layer's ports. No
async,awaitor.thenin a store; no zustandpersistmiddleware anywhere, and no AsyncStorage, MMKV, SQLite, driver, slot store or adapter import in a store (which engines the rest of the app may use is persistence-and-migrations' one-engine rule); only a store's own methods change it, neversetStatefrom outside. Why: an async action races itself and can publish a stale answer last, and a second persistence path has no version, no migration and no backup. - An undoable session lives in a per-session store over a pure session reducer.
paststays in memory for instant undo; the written form keepsstart, the command log and the snapshot; restore replays the log to rebuild undo, keeps the snapshot when the replay disagrees, and comes back paused; persist before publish. Why: a kill mid-animation loses nothing, and a relaunch never moves on unseen. - Every reducer has a test beside it that runs its cases, and every persisting store a thin store test (a subscriber already sees the value on disk; a failed write publishes nothing; a no-op writes nothing). Why: the order inside a store is invisible in review, and one moved line breaks it.
Workflow
- Read references/store-pattern.md: the store shape, both persist orders,
composition, reducers and cases tables, selectors, cross-store writes, tests, every checker rule and
the contract. Read references/state-homes.md when deciding where a piece
of state lives or when moving off Redux, Context, MobX or a
persist+ AsyncStorage store, and references/undoable-session.md before building undo. - Check the prerequisites:
src/core/result.ts(error-handling-typed-results), the data layer (persistence-and-migrations: repositories onAppData, or the document service) and the composition root (architecture-and-boundaries). Install the pinned version from the repo root:npm install -E zustand@5.0.15(stack table, verified 2026-10-03; re-verify withnpm view zustand version). When multi-package: run it in the app workspace (-w apps/<app>). - First stores: copy
templates/src/stores/(leave outsession/until step 7),templates/contracts/, and replace the Tasks lists and tasks with the app's domains, keeping each file's shape (reducer + cases + test, selectors + test, store + test). WirecreateStoresin AppRoot'sstartAppafter the data layer and the error sink, as in examples/app-root.tsx with examples/app-providers.tsx, add the stores totest/test-app.tsx, and fillcontracts/stores.json. - New action: add the failing case to
<name>-reducer-cases.tsfirst (its test runs it), then the union member and the reducer case, then the store method in the order of rule 2. - New read: add or reuse a selector; read built values through
useShallow, as examples/use-tasks-model.ts does, and change state only in handlers. - A write that spans stores: name it in
crossStoreWrites, call it throughupdateAndPublish, and give every new store holding rows areloadand a line inupdateAndPublish. - Undo: copy
templates/src/stores/session/andtemplates/test/stores/; the feature creates one session store per session in auseStateinitializer, with apersistcallback that writestoSavedSession(...)through a repository; restore withrestoreSession(it comes back paused). - Run
npx jest src/stores --ci --maxWorkers=2andnpm run typecheck. - After changing a persisting store's write order, kill and relaunch a Release build on a named device
(iOS:
xcrun simctl terminate <udid> <bundle-id>, thenxcrun simctl launch <udid> <bundle-id>; Android:adb -s <serial> shell am force-stop <package>, thenadb -s <serial> shell monkey -p <package> -c android.intent.category.LAUNCHER 1): what the screen showed must still be there. persistence-and-migrations' kill test automates the data side. - Run the checks from the repo root and fix every
FAILline (each names file, rule and fix) until both printRESULT: PASS.
Definition of done
- Every domain store comes from
create<Domain>Store(deps), is created once (createStores in the root layout and the test app; a store another skill ships with its own provider, such as the settings store, once by its own opener) and is read throughuse<Domain>Store(selector); nocreatefrom zustand, no module-level store outsideosMirrorStores. - Every store method reduces (same object: no write), persists through its port, checks the result, then publishes; failures are returned or recorded; service-fed state (an entitlement) enters through a writing store method; a memory-only store says so.
- Every reducer is pure with imperative kebab-case actions, has a cases table with a
same: truecase, and a test that runs it. - Every selector call returns a primitive or a state reference, or goes through
useShallow; no selectorless hook, no equality argument. - Every write that spans stores is in
contracts/stores.jsonand goes throughupdateAndPublish. - No
fetch, React Query or API client in a store, no effect copying query data into one, noasyncin a store, no persist middleware anywhere, no AsyncStorage or MMKV in a store. -
npx jest src/stores --ci --maxWorkers=2passes andnpm run typecheckis clean. -
node ${CLAUDE_SKILL_DIR}/scripts/check-reducers.mjs .printsRESULT: PASS(the one expected SKIP:SKIP src/stores/settings/settings-reducer-cases.ts [reducer-cases-missing] not yet created (settings-and-preferences creates it), only in an app that has not yet added that cases file). -
node ${CLAUDE_SKILL_DIR}/scripts/check-stores.mjs .printsRESULT: PASS
Anti-patterns
export const useTasksStore = create(...). A hook-store singleton shares state across tests and is created again when Fast Refresh re-runs its module; write a factory and add it tocreateStores.set(next)and then the write, or asetTimeout"debounced save". The screen shows unsaved data and a kill loses it; persist first, synchronously, then publish.- Ignoring the write's result (
tasks.setDone(id, true); set(next);). A full disk still shows the change; keep theResultand return it when it failed. useListsStore((state) => ({ count: …, failure: … })). A new object every render loops forever in Zustand 5; wrap it inuseShallowor select two primitives.useTasksStore()oruseTasksStore((s) => s)to "get everything". Both re-render on every change; select what is shown.- Removing a list with
tasks.removeList(id)and reloading only the lists store. The tasks store keeps the deleted tasks; useupdateAndPublish. useEffect(() => { setTasks(query.data) }, [query.data]). Two copies of server data drift; readuseQuerywhere it is shown and keep only client choices in a store.- An
asyncstore action that fetches and then callsset. Two calls race and the slower one wins; fetch with TanStack Query, write locally through a synchronous port. Date.now(),Math.random()orrandomUUID()in a reducer. Pass the time in the action and let the repository assign ids; reducers must replay identically.- Event-style action names (
task-added,onToggle). Name the command:add-task,set-done. - Pushing into
state.tasksorsession.past. Spread into new arrays;check-reducersdeep-freezes the input to catch it. - A publish-only Premium store (
dispatch: (action) => set(premiumReducer(get(), action))) fed by a purchase service that writes first. Two writers own one fact and the store cannot report a failed write; give the storegrantPremium(entitlement), which writes and then publishes. - Keeping undo history on disk. Save the start, the command log and the snapshot; replay on restore.
- A floating install (
npm install zustand,@latest, a caret range). It drifts from the verified pin and can be younger than the release-age policy; installzustand@5.0.15exactly.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/store-pattern.md | Store shape, persist orders, entitlements and other service-fed state, composition, actions, reducers and cases tables, selectors and useShallow, cross-store writes, document mode, tests, platform notes, multi-package, checker rules, contract, limits | Workflow step 1, and on any store change |
| references/state-homes.md | Where each kind of state lives (server, navigation, forms, UI, preferences, secrets, OS state, sessions) and moving off Redux, Context, MobX or persist + AsyncStorage | Workflow step 1, when choosing a home for state |
| references/undoable-session.md | The undoable session: types, actions, design choices, per-session store, when it is written, save and restore, property tests, the games hand-off | Workflow steps 1 and 7 |
templates/src/stores/ |
lists-* and tasks-* reducers, cases tables, selectors and stores with tests; reducer-cases.ts; stores-context.tsx; create-stores.ts; update-and-publish.ts; all tests |
Workflow steps 3 to 6 |
templates/src/stores/session/ |
Undoable session types, reducer, per-session store, saved session (save and restore), with tests | Workflow step 7 |
templates/test/stores/counter-session-rules.ts |
Tiny session rules and codec the session tests use | Workflow step 7 |
templates/contracts/ |
stores.json (cross-store writes, OS mirror stores) and its JSON schema |
Workflow step 3 |
| examples/use-tasks-model.ts | A model hook reading two stores (primitive, reference and useShallow selectors) and changing state only in handlers | Workflow step 5 |
| examples/app-root.tsx | Where createStores joins the canonical AppRoot's startApp |
Workflow step 3 |
| examples/app-providers.tsx | AppRoot's outer providers with StoresProvider after the data layer |
Workflow step 3 |
scripts/check-stores.mjs |
Static checker of the store rules over src/; options --src-dir, --stores-dir, --contract |
Workflow step 10, and at the end |
scripts/check-reducers.mjs |
Runs every reducer's cases table and the generic invariants, and the session reducer's rules, in Node | Workflow step 10, and at the end |
scripts/lib/store-rules.mjs |
check-stores' rules, one function per concern | Never by hand |
scripts/lib/store-scan.mjs |
check-stores' text helpers (calls, store initializers, builder selectors) | Never by hand |
scripts/lib/reducer-cases.mjs |
check-reducers' case-table runner and generic reducer invariants | Never by hand |
scripts/lib/session-checks.mjs |
check-reducers' undoable-session rules with the built-in counter | Never by hand |
scripts/lib/source-scan.mjs |
Shared import resolution and code masking, synced from the library (do not edit here) | Never by hand |
scripts/lib/app-modules.mjs |
Shared loader of the app's TypeScript in Node, synced from the library (do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates and examples and catch every planted bug | After changing a checker or a template |
assets/shared.json |
Declares the synced files above | When adding a shared file |
tests/ |
bases/app (the fixture app) and overlay fixtures (bad-*, pass-*, error-*) for both checkers |
When adding a checker rule |
Related skills
persistence-and-migrations- the repositories, document service and SQLite the stores write through.networking-and-server-state- server state in TanStack Query over the ApiClient port.settings-and-preferences- the settings store, its storage port and its provider.react-components-and-hooks- components and model hooks that read stores.architecture-and-boundaries- the composition root, ports and where stores sit in the layers.unit-and-component-tests- render helpers,test/test-app.tsxand the store test forms.error-handling-typed-results- theResultevery store method returns.accessibility-as-code- the OS-mirror store for the accessibility switches.game-session-host- continues, hints and outcomes on top of the undoable session (games).forms-and-input- form drafts in react-hook-form, not in a store.tdd-workflow- the test-first loop for every reducer change.