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
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
- Toggle:
internal_toggleMessageLoading
State:
- ID arrays:
messageLoadingIds, 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<T> from @/store/types for set.
Pick<ActionImpl, keyof ActionImpl> to expose only public methods.
- Export a
create*Slice helper that returns a class instance.
type Setter = StoreSetter<HomeStore>;
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<RecentActionImpl, keyof RecentActionImpl>;
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.
const createStore: StateCreator<HomeStore, [['zustand/devtools', never]]> = (...params) => ({
...initialState,
...flattenActions<HomeStoreAction>([
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<T> helper if you need to combine multiple classes and hide private fields.
type PublicActions<T> = { [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<ChatGroupAction>([
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
Do / Don't
- Do: keep constructor signature aligned with
StateCreator params (set, get, api).
- Do: use
#private to avoid set/get being exposed.
- Do: use
flattenActions instead of spreading class instances.
- Don't: keep both old slice objects and class actions active at the same time.
1---2name: zustand3description: Zustand state management guide. Use when working with store code (src/store/**), implementing actions, managing state, or creating slices. Triggers on Zustand store development, state management questions, or action implementation.4---56# LobeHub Zustand State Management78## Action Type Hierarchy910### 1. Public Actions1112Main interfaces for UI components:1314- Naming: Verb form (`createTopic`, `sendMessage`)15- Responsibilities: Parameter validation, flow orchestration1617### 2. Internal Actions (`internal_*`)1819Core business logic implementation:2021- Naming: `internal_` prefix (`internal_createTopic`)22- Responsibilities: Optimistic updates, service calls, error handling23- Should not be called directly by UI2425### 3. Dispatch Methods (`internal_dispatch*`)2627State update handlers:2829- Naming: `internal_dispatch` + entity (`internal_dispatchTopic`)30- Responsibilities: Calling reducers, updating store3132## When to Use Reducer vs Simple `set`3334**Use Reducer Pattern:**3536- Managing object lists/maps (`messagesMap`, `topicMaps`)37- Optimistic updates38- Complex state transitions3940**Use Simple `set`:**4142- Toggling booleans43- Updating simple values44- Setting single state fields4546## Optimistic Update Pattern4748```typescript49internal_createTopic: async (params) => {50 const tmpId = Date.now().toString();5152 // 1. Immediately update frontend (optimistic)53 get().internal_dispatchTopic(54 { type: 'addTopic', value: { ...params, id: tmpId } },55 'internal_createTopic'56 );5758 // 2. Call backend service59 const topicId = await topicService.createTopic(params);6061 // 3. Refresh for consistency62 await get().refreshTopic();63 return topicId;64},65```6667**Delete operations**: Don't use optimistic updates (destructive, complex recovery)6869## Naming Conventions7071**Actions:**7273- Public: `createTopic`, `sendMessage`74- Internal: `internal_createTopic`, `internal_updateMessageContent`75- Dispatch: `internal_dispatchTopic`76- Toggle: `internal_toggleMessageLoading`7778**State:**7980- ID arrays: `messageLoadingIds`, `topicEditingIds`81- Maps: `topicMaps`, `messagesMap`82- Active: `activeTopicId`83- Init flags: `topicsInit`8485## Detailed Guides8687- Action patterns: `references/action-patterns.md`88- Slice organization: `references/slice-organization.md`8990## Class-Based Action Implementation9192We are migrating slices from plain `StateCreator` objects to **class-based actions**.9394### Pattern9596- Define a class that encapsulates actions and receives `(set, get, api)` in the constructor.97- Use `#private` fields (e.g., `#set`, `#get`) to avoid leaking internals.98- Prefer shared typing helpers:99 - `StoreSetter<T>` from `@/store/types` for `set`.100 - `Pick<ActionImpl, keyof ActionImpl>` to expose only public methods.101- Export a `create*Slice` helper that returns a class instance.102103```ts104type Setter = StoreSetter<HomeStore>;105export const createRecentSlice = (set: Setter, get: () => HomeStore, _api?: unknown) =>106 new RecentActionImpl(set, get, _api);107108export class RecentActionImpl {109 readonly #get: () => HomeStore;110 readonly #set: Setter;111112 constructor(set: Setter, get: () => HomeStore, _api?: unknown) {113 void _api;114 this.#set = set;115 this.#get = get;116 }117118 useFetchRecentTopics = () => {119 // ...120 };121}122123export type RecentAction = Pick<RecentActionImpl, keyof RecentActionImpl>;124```125126### Composition127128- In store files, merge class instances with `flattenActions` (do not spread class instances).129- `flattenActions` binds methods to the original class instance and supports prototype methods and class fields.130131```ts132const createStore: StateCreator<HomeStore, [['zustand/devtools', never]]> = (...params) => ({133 ...initialState,134 ...flattenActions<HomeStoreAction>([135 createRecentSlice(...params),136 createHomeInputSlice(...params),137 ]),138});139```140141### Multi-Class Slices142143- For large slices that need multiple action classes, compose them in the slice entry using `flattenActions`.144- Use a local `PublicActions<T>` helper if you need to combine multiple classes and hide private fields.145146```ts147type PublicActions<T> = { [K in keyof T]: T[K] };148149export type ChatGroupAction = PublicActions<150 ChatGroupInternalAction & ChatGroupLifecycleAction & ChatGroupMemberAction & ChatGroupCurdAction151>;152153export const chatGroupAction: StateCreator<154 ChatGroupStore,155 [['zustand/devtools', never]],156 [],157 ChatGroupAction158> = (...params) =>159 flattenActions<ChatGroupAction>([160 new ChatGroupInternalAction(...params),161 new ChatGroupLifecycleAction(...params),162 new ChatGroupMemberAction(...params),163 new ChatGroupCurdAction(...params),164 ]);165```166167### Store-Access Types168169- For class methods that depend on actions in other classes, define explicit store augmentations:170 - `ChatGroupStoreWithSwitchTopic` for lifecycle `switchTopic`171 - `ChatGroupStoreWithRefresh` for member refresh172 - `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`173174### Do / Don't175176- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.177- **Do**: use `#private` to avoid `set/get` being exposed.178- **Do**: use `flattenActions` instead of spreading class instances.179- **Don't**: keep both old slice objects and class actions active at the same time.