PluginBench
Skill
Review
Audit score 70

zustand

lobehub/lobe-chat

LobeHub Zustand state management conventions for store slices, actions, and optimistic updates.

What is zustand?

Reference guide for implementing Zustand stores in LobeHub following established patterns. Use when editing store slices, actions, dispatch methods, optimistic updates, selectors, or migrating to class-based actions.

  • Defines action type hierarchy (public, internal, dispatch) with naming conventions
  • Specifies when to use reducer pattern vs simple set for state updates
  • Documents optimistic update pattern with temporary IDs and backend reconciliation
  • Provides class-based action implementation with private fields and composition
  • Guides slice organization and multi-class action composition using flattenActions
  • Covers store-access types and handling slices that don't require local state writes

How to install zustand

npx skills add https://github.com/lobehub/lobe-chat --skill zustand
Claude Code
Cursor
Windsurf
Cline

How to use zustand

  1. 1.Review the action type hierarchy to determine if you need public, internal, or dispatch methods
  2. 2.Choose reducer pattern for complex state (maps, lists, optimistic updates) or simple set for primitives
  3. 3.For new features, implement public action for UI interface, internal action for business logic, and dispatch for state updates
  4. 4.When migrating to class-based actions, define a class with private #set and #get fields and export a create*Slice helper
  5. 5.Use flattenActions to compose multiple action classes in store creation, not spread operator
  6. 6.Define store-access type augmentations (e.g., ChatGroupStoreWithRefresh) when classes depend on actions from other classes

Use cases

Good for
  • Adding a new public action like createTopic with proper parameter validation and flow orchestration
  • Implementing optimistic updates for create operations with temporary IDs and backend sync
  • Migrating a plain StateCreator slice to class-based actions with private fields
  • Composing multiple action classes in a single slice using flattenActions
  • Handling cross-slice dependencies through store-access type augmentations
Who it's for
  • LobeHub contributors editing src/store or store slices
  • Developers implementing Zustand state management in LobeHub
  • Engineers migrating legacy slices to class-based action patterns

zustand FAQ

When should I use optimistic updates?

Use optimistic updates for create operations where you can generate a temporary ID and refresh from backend. Avoid for delete operations due to complexity of recovery on failure.

What's the difference between internal_ and internal_dispatch* methods?

internal_* methods contain business logic (validation, service calls, error handling) and call internal_dispatch* methods. internal_dispatch* methods are state update handlers that call reducers and update the store.

Should I use a reducer or simple set for my state field?

Use reducer pattern for managing object lists/maps, optimistic updates, and complex state transitions. Use simple set for toggling booleans, updating simple values, or setting single state fields.

How do I compose multiple action classes in one slice?

Define each action class with (set, get, api) constructor, then use flattenActions([classInstance1, classInstance2]) in the store creation—do not use spread operator on class instances.

What if my slice doesn't need to write local state?

Mark the set parameter as _set and call void _set in the constructor to maintain the (set, get, api) shape. Drop the #set private field entirely.

Full instructions (SKILL.md)

Source of truth, from lobehub/lobe-chat.


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

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<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

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:

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.