Imported from lobehub/lobehub (
.agents/skills/zustand/SKILL.md) via skills.sh. Install upstream withnpx skills add lobehub/lobehub --skill zustand. Copyright stays with the author.
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 singlecurrentDetailobject. - Read through
xxxSelectorsaggregates (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.
| File | Owns |
|---|---|
store.ts |
useXStore = createWithEqualityFn<XState>()(devtools(() => initialState), shallow): state only, no methods |
privateAction.ts |
XPrivateAction: replica slices, entity links, and write helpers shared by public actions. Not exported from index.ts |
action.ts |
XActionImpl (the public API) and the singleton xActions = new XActionImpl(setState, getState, new XPrivateAction(…)) |
- Components read state with
useXStore(selector)and callxActions.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 throughthis.#private. Do not addinternal_*methods to the store to share logic between classes. - Label every write:
this.#set(partial, false, n('actionName'))withconst n = setNamespace('x'). - Put
reset()on the public class and registerxActionsinresetableActionsinsrc/store/utils/userDataStores.ts. - Tests drive
xActionsand assert onuseXStore.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.
- Define the resource (
defineReplica/definePagedReplica) in the store'sprojection.ts, converting server rows at the boundary. - Add two state fields per replica: the view (
xxxMap: Record<key, TData>) and its bookkeeping (xxxReplica: createReplicaState()). - 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 withlinkReplicaEntity([list, detail]). - Read:
slice.useSync(params)returns only fetch flags (isHydrated,isValidating,error,revalidate). Data comes from selectors overxxxMap. "Entry isundefined" is the loading signal — do not addxxxInitflags orloadingXxxIds. - Read with no view mounted (hover prefetch, refresh after a mutation):
await slice.fetch(params)({ fresh: true }after a write). Never call the service andslice.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 sliceeffects(onApplied/onMissing/onPurge/onError) withisMissing, not in one hook'sonSuccess. - Cold start without a skeleton:
useSynchydrates only after the component mounts. For a list that must paint from the local copy, add apreHydrateaction (ensureScope+hydrate) and call it from the route'sloaderthrough a lazy wrapper (src/spa/router/pageListLoader.ts,agentChatTopicListLoader.ts) bounded bysettleWithin(…, PRE_PAINT_HYDRATE_TIMEOUT)from@/libs/replica/prePaint.@/libs/replicapresets the cache scope, IndexedDB / localStorage storage (AppIndexedDBStorage/AppLocalStoragein@/libs/replica/storage) and the app's SWR driver; the package README stays app-agnostic. - Write:
slice.optimistic(key, apply, serverCall)orentity.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 withisClientOnlyand seeded withupdate(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.
