SKILL.md
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
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
#privatefields (e.g.,#set,#get) to avoid leaking internals.
- Prefer shared typing helpers:
StoreSetter<T>from@/store/typesforset.
Pick<ActionImpl, keyof ActionImpl>to expose only public methods.
- Export a
create*Slicehelper 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).
flattenActionsbinds 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:
ChatGroupStoreWithSwitchTopicfor lifecycleswitchTopic
ChatGroupStoreWithRefreshfor member refresh
ChatGroupStoreWithInternalfor curdinternal_dispatchChatGroup
Slices That Don't Currently Need set
When a slice doesn't write local state at the moment — e.g. it reads context
from #get() and forwards calls to another store, or just runs hooks — drop
the #set field. Otherwise ESLint's no-unused-vars flags the unused private
field.
Mark the constructor's set param as _set and void _set it to keep the
(set, get, api) shape aligned with StateCreator. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs set,
restore the #set field and use it; do not invent a workaround to keep the
"unused" form.
type Setter = StoreSetter<ConversationStore>;
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
new ToolActionImpl(set, get, _api);
export class ToolActionImpl {
readonly #get: () => ConversationStore;
// Mark unused params with `_` prefix and `void _x` so the constructor still
// matches StateCreator's `(set, get, api)` shape without triggering unused
// diagnostics.
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);
};
}
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
Rules of thumb:
- If a slice doesn't currently call
set, drop#set(use_set+void _set
in the constructor). When a later edit needs set, restore #set and use it.
- Don't add
setNamespacefor slices that don't write state. Add it when the
slice starts writing state.
- Never leave
#setdeclared but unused "for future use" — lint will fail and
re-adding it later costs nothing.
Do / Don't
- Do: keep constructor signature aligned with
StateCreatorparams(set, get, api).
- Do: use
#privateto avoidset/getbeing exposed.
- Do: use
flattenActionsinstead of spreading class instances.
- Do: drop
#set(and use_set+void _setin the constructor) for
delegate-only slices that never write state — keeps lint green without
breaking the (set, get, api) shape.
- Don't: keep both old slice objects and class actions active at the same time.
- Don't: keep an unused
#setfield "for future use" — it fails ESLint and
re-adding it later costs nothing.