mcpField
Z
zustand

zustand

github.com/lobehubDevelopment83.6k6.9k installsUpdated today
Repository

About zustand

A LobeHub development skill describing conventions for Zustand state management in the project. It covers state-only stores, replica-backed server data, and how to handle legacy stores during edits or migrations.

Key features

  1. 01Define state-only store structure
  2. 02Separate public and private actions
  3. 03Manage server data with replicas
  4. 04Handle hydration and optimistic updates
  5. 05Follow legacy store conventions

Use cases

  1. 01Create or migrate a Zustand store
  2. 02Add replica-backed list and detail data
  3. 03Implement optimistic writes with rollback

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/lobehub/lobehub --skill zustand

After installing, type/zustandin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

references/
data-structures.md
legacy-slices.md
references/data-structures/
reducer.md
types.md

Details

From the project README

LobeHub Zustand State Management

State Shapes and Types

  • Import shared store types from @lobechat/types, not @lobechat/database.
  • Keep lightweight list-item types separate from full detail types; list types must not extend heavy detail types.
  • Key cached details by id (xxxDetailMap: Record<string, Detail>); never hold a single currentDetail object.
  • Read through xxxSelectors aggregates (selectors.ts), not ad-hoc lambdas repeated across components.
  • Before choosing list/detail shapes or type sources, read Data structures.

State-Only Stores (default for new and migrated stores)

Actions stay out of the Zustand state. Reference: src/store/page.

FileOwns
store.tsuseXStore = createWithEqualityFn<XState>()(devtools(() => initialState), shallow): state only, no methods
privateAction.tsXPrivateAction: replica slices, entity links, and write helpers shared by public actions. Not exported from index.ts
action.tsXActionImpl (the public API) and the singleton xActions = new XActionImpl(setState, getState, new XPrivateAction(…))
  • Components read state with useXStore(selector) and call xActions.foo() directly. Never select an action out of the store.
  • Sync hooks live on the public class and are called as xActions.useFetchX(params).
  • Public actions call each other through this.foo(). Private helpers are reached through this.#private. Do not add internal_* methods to the store to share logic between classes.
  • Label every write: this.#set(partial, false, n('actionName')) with const n = setNamespace('x').
  • Put reset() on the public class and register xActions in resetableActions in src/store/utils/userDataStores.ts.
  • Tests drive xActions and assert on useXStore.getState(). To mock in component tests: vi.mock('@/store/x', () => ({ xActions: { foo: vi.fn() }, useXStore: … })).

Replica-Backed Server Data

Server lists and details the UI paints go through @/libs/replica, not a hand-written SWR onSuccess + reducer. The engine owns hydration from IndexedDB, head revalidation, paging, optimistic overlays with rollback, and scope (user + workspace) isolation. API reference: the local-replica README. The engine is maintained in lobehub/local-replica: fix it there, publish, then bump the dependency here.

  1. Define the resource (defineReplica / definePagedReplica) in the store's projection.ts, converting server rows at the boundary.
  2. Add two state fields per replica: the view (xxxMap: Record<key, TData>) and its bookkeeping (xxxReplica: createReplicaState()).
  3. Bind it in the private class: createReplicaSlice(resource, { get, set, stateKey: 'xxxReplica', view: recordLens('xxxMap'), … }). When the same entity lives in several replicas (list row + detail), link them with linkReplicaEntity([list, detail]).
  4. Read: slice.useSync(params) returns only fetch flags (isHydrated, isValidating, error, revalidate). Data comes from selectors over xxxMap. "Entry is undefined" is the loading signal — do not add xxxInit flags or loadingXxxIds.
  5. Read with no view mounted (hover prefetch, refresh after a mutation): await slice.fetch(params) ({ fresh: true } after a write). Never call the service and slice.replace(...) by hand, because that skips the scope, removal, clear and superseded-request guards. Side effects of every response (not-found maps, seeding another store) go in the slice effects (onApplied / onMissing / onPurge / onError) with isMissing, not in one hook's onSuccess.
  6. Cold start without a skeleton: useSync hydrates only after the component mounts. For a list that must paint from the local copy, add a preHydrate action (ensureScope + hydrate) and call it from the route's loader through a lazy wrapper (src/spa/router/pageListLoader.ts, agentChatTopicListLoader.ts) bounded by settleWithin(…, PRE_PAINT_HYDRATE_TIMEOUT) from @/libs/replica/prePaint. @/libs/replica presets the cache scope, IndexedDB / localStorage storage (AppIndexedDBStorage / AppLocalStorage in @/libs/replica/storage) and the app's SWR driver; the package README stays app-agnostic.
  7. Write: slice.optimistic(key, apply, serverCall) or entity.optimistic(id, fn | 'remove', serverCall). A failed call rolls back to the confirmed value, so deletes are optimistic too. Rows that exist only on the client (temp ids) are declared with isClientOnly and seeded with update(key, fn, { persist: false }).

Outside test seeding, write a replica's view only through its slice. A plain set bypasses the engine's bookkeeping (persistence, rollback base, entity links).

Legacy Stores

Stores that still flatten action classes into state (flattenActions, create*Slice, internal_*, internal_dispatch* + reducers, SWR onSuccess syncing) follow Legacy slices. Use that layout only when editing one of those stores; migrate to the layouts above when moving its data onto a replica.

Comments

Markdown supported