AgentSkillsCN

creating-zustand-stores

为 StickerNest 的状态管理创建 Zustand 存储。适用于用户提出“创建存储”、“添加状态管理”、“构建新存储”、“管理全局状态”、“持久化状态”,或“添加新应用状态”时使用。涵盖存储结构、持久化中间件、开发者工具、选择器,以及动作。

SKILL.md
--- frontmatter
name: creating-zustand-stores
description: Creating Zustand stores for StickerNest state management. Use when the user asks to create a store, add state management, build a new store, manage global state, persist state, or add new application state. Covers store structure, persist middleware, devtools, selectors, and actions.

Creating Zustand Stores for StickerNest

This skill covers creating Zustand stores following StickerNest's established patterns, including TypeScript types, middleware configuration, selectors, and actions.

Store Location

All stores are located in src/state/. Follow the naming convention use{Feature}Store.ts.


Basic Store Template

typescript
// src/state/useMyFeatureStore.ts

/**
 * StickerNest v2 - My Feature Store (Zustand)
 * Brief description of what this store manages
 */

import { create } from 'zustand';

// ==================
// Types
// ==================

/** Feature item type */
export interface MyItem {
  id: string;
  name: string;
  value: number;
  createdAt: string;
}

// ==================
// Store State
// ==================

export interface MyFeatureState {
  /** List of items */
  items: MyItem[];
  /** Currently selected item ID */
  selectedId: string | null;
  /** Loading state */
  isLoading: boolean;
  /** Error message if any */
  error: string | null;
}

// ==================
// Store Actions
// ==================

export interface MyFeatureActions {
  /** Add a new item */
  addItem: (item: Omit<MyItem, 'id' | 'createdAt'>) => void;
  /** Remove an item by ID */
  removeItem: (id: string) => void;
  /** Update an existing item */
  updateItem: (id: string, updates: Partial<MyItem>) => void;
  /** Select an item */
  selectItem: (id: string | null) => void;
  /** Set loading state */
  setLoading: (loading: boolean) => void;
  /** Set error state */
  setError: (error: string | null) => void;
  /** Reset store to initial state */
  reset: () => void;
}

// ==================
// Initial State
// ==================

const initialState: MyFeatureState = {
  items: [],
  selectedId: null,
  isLoading: false,
  error: null,
};

// ==================
// Store Creation
// ==================

export const useMyFeatureStore = create<MyFeatureState & MyFeatureActions>()(
  (set, get) => ({
    ...initialState,

    addItem: (item) => {
      const newItem: MyItem = {
        ...item,
        id: crypto.randomUUID(),
        createdAt: new Date().toISOString(),
      };
      set((state) => ({
        items: [...state.items, newItem],
      }));
    },

    removeItem: (id) => {
      set((state) => ({
        items: state.items.filter((item) => item.id !== id),
        selectedId: state.selectedId === id ? null : state.selectedId,
      }));
    },

    updateItem: (id, updates) => {
      set((state) => ({
        items: state.items.map((item) =>
          item.id === id ? { ...item, ...updates } : item
        ),
      }));
    },

    selectItem: (id) => {
      set({ selectedId: id });
    },

    setLoading: (isLoading) => {
      set({ isLoading });
    },

    setError: (error) => {
      set({ error });
    },

    reset: () => {
      set(initialState);
    },
  })
);

// ==================
// Selector Hooks
// ==================

export const useMyItems = () => useMyFeatureStore((state) => state.items);
export const useSelectedItemId = () => useMyFeatureStore((state) => state.selectedId);
export const useSelectedItem = () =>
  useMyFeatureStore((state) =>
    state.items.find((item) => item.id === state.selectedId)
  );
export const useMyFeatureLoading = () => useMyFeatureStore((state) => state.isLoading);
export const useMyFeatureError = () => useMyFeatureStore((state) => state.error);

Store with Persist Middleware

For stores that should persist data to localStorage:

typescript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

export const useMyFeatureStore = create<MyFeatureState & MyFeatureActions>()(
  persist(
    (set, get) => ({
      ...initialState,

      // Actions...
    }),
    {
      name: 'my-feature-store', // localStorage key
      storage: createJSONStorage(() => localStorage),

      // Optional: Only persist certain fields
      partialize: (state) => ({
        items: state.items,
        // Don't persist: isLoading, error, selectedId
      }),

      // Optional: Version for migrations
      version: 1,

      // Optional: Migration function
      migrate: (persistedState: any, version: number) => {
        if (version === 0) {
          // Migrate from v0 to v1
          return {
            ...persistedState,
            // Add new fields or transform data
          };
        }
        return persistedState;
      },
    }
  )
);

Store with Devtools Middleware

For debugging with Redux DevTools:

typescript
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useMyFeatureStore = create<MyFeatureState & MyFeatureActions>()(
  devtools(
    (set, get) => ({
      ...initialState,

      addItem: (item) => {
        set(
          (state) => ({
            items: [...state.items, { ...item, id: crypto.randomUUID() }],
          }),
          false, // replace: false (merge)
          'addItem' // Action name for devtools
        );
      },

      // Other actions...
    }),
    {
      name: 'MyFeatureStore', // Store name in devtools
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

Combined Persist + Devtools

typescript
import { create } from 'zustand';
import { persist, devtools, createJSONStorage } from 'zustand/middleware';

export const useMyFeatureStore = create<MyFeatureState & MyFeatureActions>()(
  devtools(
    persist(
      (set, get) => ({
        ...initialState,
        // Actions...
      }),
      {
        name: 'my-feature-store',
        storage: createJSONStorage(() => localStorage),
      }
    ),
    {
      name: 'MyFeatureStore',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

Common Patterns

Map-based State (for entities)

typescript
export interface EntityState {
  entities: Map<string, Entity>;
}

export interface EntityActions {
  addEntity: (entity: Entity) => void;
  removeEntity: (id: string) => void;
  getEntity: (id: string) => Entity | undefined;
}

export const useEntityStore = create<EntityState & EntityActions>()(
  (set, get) => ({
    entities: new Map(),

    addEntity: (entity) => {
      set((state) => ({
        entities: new Map(state.entities).set(entity.id, entity),
      }));
    },

    removeEntity: (id) => {
      set((state) => {
        const newEntities = new Map(state.entities);
        newEntities.delete(id);
        return { entities: newEntities };
      });
    },

    getEntity: (id) => {
      return get().entities.get(id);
    },
  })
);

Nested State Updates

typescript
export interface NestedState {
  settings: {
    appearance: {
      theme: 'light' | 'dark';
      fontSize: number;
    };
    behavior: {
      autoSave: boolean;
      notifications: boolean;
    };
  };
}

export interface NestedActions {
  setTheme: (theme: 'light' | 'dark') => void;
  setFontSize: (size: number) => void;
  toggleAutoSave: () => void;
}

export const useSettingsStore = create<NestedState & NestedActions>()(
  (set, get) => ({
    settings: {
      appearance: { theme: 'dark', fontSize: 14 },
      behavior: { autoSave: true, notifications: true },
    },

    setTheme: (theme) => {
      set((state) => ({
        settings: {
          ...state.settings,
          appearance: {
            ...state.settings.appearance,
            theme,
          },
        },
      }));
    },

    setFontSize: (fontSize) => {
      set((state) => ({
        settings: {
          ...state.settings,
          appearance: {
            ...state.settings.appearance,
            fontSize,
          },
        },
      }));
    },

    toggleAutoSave: () => {
      set((state) => ({
        settings: {
          ...state.settings,
          behavior: {
            ...state.settings.behavior,
            autoSave: !state.settings.behavior.autoSave,
          },
        },
      }));
    },
  })
);

Async Actions

typescript
export interface AsyncState {
  data: DataType | null;
  isLoading: boolean;
  error: string | null;
}

export interface AsyncActions {
  fetchData: () => Promise<void>;
  saveData: (data: DataType) => Promise<void>;
}

export const useAsyncStore = create<AsyncState & AsyncActions>()(
  (set, get) => ({
    data: null,
    isLoading: false,
    error: null,

    fetchData: async () => {
      set({ isLoading: true, error: null });
      try {
        const response = await fetch('/api/data');
        const data = await response.json();
        set({ data, isLoading: false });
      } catch (err) {
        set({
          error: err instanceof Error ? err.message : 'Unknown error',
          isLoading: false,
        });
      }
    },

    saveData: async (data) => {
      set({ isLoading: true, error: null });
      try {
        await fetch('/api/data', {
          method: 'POST',
          body: JSON.stringify(data),
        });
        set({ data, isLoading: false });
      } catch (err) {
        set({
          error: err instanceof Error ? err.message : 'Unknown error',
          isLoading: false,
        });
      }
    },
  })
);

Computed Values (Derived State)

typescript
export interface ComputedState {
  items: Item[];
  filter: string;
}

export interface ComputedActions {
  setFilter: (filter: string) => void;
  addItem: (item: Item) => void;
}

// Selectors for computed values
export const useFilteredItems = () =>
  useComputedStore((state) => {
    const { items, filter } = state;
    if (!filter) return items;
    return items.filter((item) =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  });

export const useItemCount = () =>
  useComputedStore((state) => state.items.length);

export const useHasItems = () =>
  useComputedStore((state) => state.items.length > 0);

Subscribing to Store Changes

typescript
// Subscribe to all changes
const unsubscribe = useMyFeatureStore.subscribe((state) => {
  console.log('State changed:', state);
});

// Subscribe to specific slice
const unsubscribe = useMyFeatureStore.subscribe(
  (state) => state.selectedId,
  (selectedId) => {
    console.log('Selected ID changed:', selectedId);
  }
);

// Don't forget to unsubscribe
unsubscribe();

Testing Stores

typescript
// src/state/useMyFeatureStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useMyFeatureStore } from './useMyFeatureStore';

describe('useMyFeatureStore', () => {
  beforeEach(() => {
    // Reset store before each test
    useMyFeatureStore.getState().reset();
  });

  it('should add item', () => {
    const { addItem } = useMyFeatureStore.getState();

    addItem({ name: 'Test', value: 42 });

    const { items } = useMyFeatureStore.getState();
    expect(items).toHaveLength(1);
    expect(items[0].name).toBe('Test');
    expect(items[0].value).toBe(42);
    expect(items[0].id).toBeDefined();
  });

  it('should remove item', () => {
    const { addItem, removeItem } = useMyFeatureStore.getState();

    addItem({ name: 'Test', value: 42 });
    const { items: itemsAfterAdd } = useMyFeatureStore.getState();
    const itemId = itemsAfterAdd[0].id;

    removeItem(itemId);

    const { items } = useMyFeatureStore.getState();
    expect(items).toHaveLength(0);
  });

  it('should update item', () => {
    const { addItem, updateItem } = useMyFeatureStore.getState();

    addItem({ name: 'Test', value: 42 });
    const { items: itemsAfterAdd } = useMyFeatureStore.getState();
    const itemId = itemsAfterAdd[0].id;

    updateItem(itemId, { value: 100 });

    const { items } = useMyFeatureStore.getState();
    expect(items[0].value).toBe(100);
    expect(items[0].name).toBe('Test'); // Unchanged
  });

  it('should select item', () => {
    const { addItem, selectItem } = useMyFeatureStore.getState();

    addItem({ name: 'Test', value: 42 });
    const { items } = useMyFeatureStore.getState();

    selectItem(items[0].id);

    const { selectedId } = useMyFeatureStore.getState();
    expect(selectedId).toBe(items[0].id);
  });
});

Existing Stores Reference

StoreFilePurpose
useCanvasStoreuseCanvasStore.tsCanvas state, widgets, selection
useLibraryStoreuseLibraryStore.tsWidget library, search, filters
usePanelsStoreusePanelsStore.tsPanel visibility and positions
useToolStoreuseToolStore.tsActive tools and defaults
useThemeStoreuseThemeStore.tsTheme settings
useStickerStoreuseStickerStore.tsSticker/asset management
useApiSettingsStoreuseApiSettingsStore.tsAPI configuration
useAssetStoreuseAssetStore.tsAsset management
useSelectionStoreuseSelectionStore.tsSelection state
useSlotStoreuseSlotStore.tsSkin slot management
useSkinStoreuseSkinStore.tsSkin management
useCanvasExtendedStoreuseCanvasExtendedStore.tsExtended viewport state
useRuntimeStoreuseRuntimeStore.tsRuntime state
useCanvasRouterStoreuseCanvasRouterStore.tsCanvas routing
entityStoreentityStore.tsEntity management

Best Practices

  1. Keep stores focused - One store per feature/domain
  2. Use TypeScript - Define interfaces for state and actions
  3. Separate state and actions - Makes types clearer
  4. Create selector hooks - For common access patterns
  5. Use persist sparingly - Only for data that must survive refresh
  6. Include a reset action - For testing and cleanup
  7. Use devtools in development - For debugging
  8. Avoid storing derived data - Compute in selectors instead