--- name: zustand description: 'LobeHub Zustand store conventions. Use when editing src/store, store slices, public/internal actions, dispatch actions, flattenActions, optimistic updates, selectors, maps, or class action migration.' user-invocable: false --- # LobeHub Zustand State Management ## Action Type Hierarchy ### 1. Public Actions Main interfaces for UI components: - Naming: Verb form (`createTopic`, `sendMessage`) - Responsibilities: Parameter validation, flow orchestration ### 2. Internal Actions (`internal_*`) Core business logic implementation: - Naming: `internal_` prefix (`internal_createTopic`) - Responsibilities: Optimistic updates, service calls, error handling - Should not be called directly by UI ### 3. Dispatch Methods (`internal_dispatch*`) State update handlers: - Naming: `internal_dispatch` + entity (`internal_dispatchTopic`) - Responsibilities: Calling reducers, updating store ## When to Use Reducer vs Simple `set` **Use Reducer Pattern:** - Managing object lists/maps (`messagesMap`, `topicMaps`) - Optimistic updates - Complex state transitions **Use Simple `set`:** - Toggling booleans - Updating simple values - Setting single state fields ## Optimistic Update Pattern ```typescript internal_createTopic: async (params) => { const tmpId = Date.now().toString(); // 1. Immediately update frontend (optimistic) get().internal_dispatchTopic( { type: 'addTopic', value: { ...params, id: tmpId } }, 'internal_createTopic' ); // 2. Call backend service const topicId = await topicService.createTopic(params); // 3. Refresh for consistency await get().refreshTopic(); return topicId; }, ``` **Delete operations**: Don't use optimistic updates (destructive, complex recovery) ## Naming Conventions **Actions:** - Public: `createTopic`, `sendMessage` - Internal: `internal_createTopic`, `internal_updateMessageContent` - Dispatch: `internal_dispatchTopic` **State:** - ID arrays: `topicEditingIds` - Maps: `topicMaps`, `messagesMap` - Active: `activeTopicId` - Init flags: `topicsInit` ## Detailed Guides - Action patterns: `references/action-patterns.md` - Slice organization: `references/slice-organization.md` ## Class-Based Action Implementation We are migrating slices from plain `StateCreator` objects to **class-based actions**. ### Pattern - Define a class that encapsulates actions and receives `(set, get, api)` in the constructor. - Use `#private` fields (e.g., `#set`, `#get`) to avoid leaking internals. - Prefer shared typing helpers: - `StoreSetter` from `@/store/types` for `set`. - `Pick` to expose only public methods. - Export a `create*Slice` helper that returns a class instance. ```ts type Setter = StoreSetter; export const createRecentSlice = (set: Setter, get: () => HomeStore, _api?: unknown) => new RecentActionImpl(set, get, _api); export class RecentActionImpl { readonly #get: () => HomeStore; readonly #set: Setter; constructor(set: Setter, get: () => HomeStore, _api?: unknown) { void _api; this.#set = set; this.#get = get; } useFetchRecentTopics = () => { // ... }; } export type RecentAction = Pick; ``` ### Composition - In store files, merge class instances with `flattenActions` (do not spread class instances). - `flattenActions` binds methods to the original class instance and supports prototype methods and class fields. ```ts const createStore: StateCreator = (...params) => ({ ...initialState, ...flattenActions([ createRecentSlice(...params), createHomeInputSlice(...params), ]), }); ``` ### Multi-Class Slices - For large slices that need multiple action classes, compose them in the slice entry using `flattenActions`. - Use a local `PublicActions` helper if you need to combine multiple classes and hide private fields. ```ts type PublicActions = { [K in keyof T]: T[K] }; export type ChatGroupAction = PublicActions< ChatGroupInternalAction & ChatGroupLifecycleAction & ChatGroupMemberAction & ChatGroupCurdAction >; export const chatGroupAction: StateCreator< ChatGroupStore, [['zustand/devtools', never]], [], ChatGroupAction > = (...params) => flattenActions([ new ChatGroupInternalAction(...params), new ChatGroupLifecycleAction(...params), new ChatGroupMemberAction(...params), new ChatGroupCurdAction(...params), ]); ``` ### Store-Access Types - For class methods that depend on actions in other classes, define explicit store augmentations: - `ChatGroupStoreWithSwitchTopic` for lifecycle `switchTopic` - `ChatGroupStoreWithRefresh` for member refresh - `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup` ### Slices That Don't Currently Need `set` When a slice doesn't write local state (e.g. it delegates to another store or just runs hooks), drop `#set` and mark the constructor param as `_set` with `void _set` to keep the `(set, get, api)` shape: ```ts export class ToolActionImpl { readonly #get: () => ConversationStore; constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) { void _set; void _api; this.#get = get; } approveToolCall = async (id: string) => { const { context, hooks } = this.#get(); await useChatStore.getState().approveToolCalling(id, '', context); hooks.onToolCallComplete?.(id, undefined); }; } ``` - Drop `#set` when unused; restore it when a later edit needs `set` — re-adding costs nothing. - Don't add `setNamespace` for slices that don't write state. - Don't keep both old slice objects and class actions active at the same time during migration.