搜索

PythonReactSEOPDF
GitHub Skill

zustand-store

Create Zustand stores for IntelliFill with immer, persist, and devtools middleware. Use when adding state management for new features.

Intellifill☆ 1 仓库 Stars0 仓库 Forks

安装与使用

请根据来源仓库和你的 AI 工具说明安装此技能。先阅读技能文件和依赖要求,再添加到工作流。

查看来源 ↗使用指南

技能说明

Zustand Store Development Skill

This skill provides comprehensive guidance for creating Zustand stores in the IntelliFill frontend (quikadmin-web/).

Table of Contents

  1. Store Architecture
  2. Basic Store Pattern
  3. Middleware Stack
  4. Selectors and Hooks
  5. Async Actions
  6. Persistence
  7. Testing Stores
  8. Best Practices

Store Architecture

IntelliFill organizes Zustand stores by domain:

quikadmin-web/src/stores/
├── authStore.ts              # Authentication state
├── documentStore.ts          # Document management
├── templateStore.ts          # Template management
├── knowledgeStore.ts         # Knowledge base
├── uiStore.ts                # UI state (modals, sidebar, etc.)
└── __tests__/                # Store tests
    ├── documentStore.test.ts
    └── ...

Basic Store Pattern

IntelliFill uses a consistent pattern for all stores.

Simple Store Template
// quikadmin-web/src/stores/[domain]Store.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { devtools } from 'zustand/middleware';

// Types
interface Item {
  id: string;
  name: string;
  createdAt: string;
}

interface DomainState {
  // Data
  items: Item[];
  selectedItem: Item | null;

  // UI State
  loading: boolean;
  error: string | null;

  // Actions
  fetchItems: () => Promise<void>;
  getItem: (id: string) => Promise<void>;
  createItem: (data: Partial<Item>) => Promise<void>;
  updateItem: (id: string, data: Partial<Item>) => Promise<void>;
  deleteItem: (id: string) => Promise<void>;
  setSelectedItem: (item: Item | null) => void;
  clearError: () => void;
}

// Store
export const useDomainStore = create<DomainState>()(
  devtools(
    immer((set, get) => ({
      // Initial state
      items: [],
      selectedItem: null,
      loading: false,
      error: null,

      // Actions
      fetchItems: async () => {
        set({ loading: true, error: null });
        try {
          const response = await api.get('/domain');
          set({ items: response.data, loading: false });
        } catch (error) {
          set({
            error: 'Failed to fetch items',
            loading: false,
          });
        }
      },

      getItem: async (id: string) => {
        set({ loading: true, error: null });
        try {
          const response = await api.get(`/domain/${id}`);
          set({ selectedItem: response.data, loading: false });
        } catch (error) {
          set({
            error: 'Failed to fetch item',
            loading: false,
          });
        }
      },

      createItem: async (data: Partial<Item>) => {
        set({ loading: true, error: null });
        try {
          const response = await api.post('/domain', data);
          set((state) => {
            state.items.push(response.data);
            state.loading = false;
          });
        } catch (error) {
          set({
            error: 'Failed to create item',
            loading: false,
          });
          throw error;
        }
      },

      updateItem: async (id: string, data: Partial<Item>) => {
        set({ loading: true, error: null });
        try {
          const response = await api.patch(`/domain/${id}`, data);
          set((state) => {
            const index = state.items.findIndex((item) => item.id === id);
            if (index !== -1) {
              state.items[index] = response.data;
            }
            if (state.selectedItem?.id === id) {
              state.selectedItem = response.data;
            }
            state.loading = false;
          });
        } catch (error) {
          set({
            error: 'Failed to update item',
            loading: false,
          });
          throw error;
        }
      },

      deleteItem: async (id: string) => {
        set({ loading: true, error: null });
        try {
          await api.delete(`/domain/${id}`);
          set((state) => {
            state.items = state.items.filter((item) => item.id !== id);
            if (state.selectedItem?.id === id) {
              state.selectedItem = null;
            }
            state.loading = false;
          });
        } catch (error) {
          set({
            error: 'Failed to delete item',
            loading: false,
          });
          throw error;
        }
      },

      setSelectedItem: (item: Item | null) => {
        set({ selectedItem: item });
      },

      clearError: () => {
        set({ error: null });
      },
    })),
    { name: 'DomainStore' }
  )
);

Middleware Stack

IntelliFill uses three middleware layers: immer, persist, and devtools.

Middleware Order
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

// Correct order: devtools(persist(immer(...)))
export const useStore = create<State>()(
  devtools(
    persist(
      immer((set, get) => ({
        // Store implementation
      })),
      {
        name: 'store-name',
      }
    ),
    { name: 'StoreName' }
  )
);
Immer Middleware

Immer allows direct state mutation (safely):

import { immer } from 'zustand/middleware/immer';

export const useStore = create<State>()(
  immer((set, get) => ({
    items: [],

    // WITHOUT immer (immutable updates)
    addItemOld: (item) => {
      set((state) => ({
        items: [...state.items, item],
      }));
    },

    // WITH immer (mutable style)
    addItem: (item) => {
      set((state) => {
        state.items.push(item); // Direct mutation!
      });
    },

    // Nested updates are easier
    updateNested: (id, value) => {
      set((state) => {
        const item = state.items.find((i) => i.id === id);
        if (item) {
          item.nested.deeply.value = value; // Easy!
        }
      });
    },
  }))
);
Persist Middleware

Persist store state to localStorage:

import { persist } from 'zustand/middleware';

export const useStore = create<State>()(
  persist(
    immer((set, get) => ({
      items: [],
      preferences: {},
    })),
    {
      name: 'domain-storage', // localStorage key

      // Partial persistence (only save specific fields)
      partialPersist: {
        preferences: true, // Save preferences
        items: false, // Don't save items
      },

      // Version for migrations
      version: 1,

      // Migration function
      migrate: (persistedState: any, version: number) => {
        if (version === 0) {
          // Migrate from v0 to v1
          persistedState.newField = 'default';
        }
        return persistedState;
      },
    }
  )
);
Devtools Middleware

Enable Redux DevTools integration:

import { devtools } from 'zustand/middleware';

export const useStore = create<State>()(
  devtools(
    immer((set, get) => ({
      count: 0,

      increment: () => {
        set((state) => {
          state.count++;
        }, false, 'increment'); // Action name in devtools
      },

      decrement: () => {
        set((state) => {
          state.count--;
        }, false, { type: 'decrement', payload: -1 }); // Detailed action
      },
    })),
    {
      name: 'CounterStore', // Store name in devtools
      enabled: process.env.NODE_ENV === 'development', // Only in dev
    }
  )
);

Selectors and Hooks

Optimize re-renders with selectors.

Basic Selectors
// Component re-renders only when items change
function ItemList() {
  const items = useDomainStore((state) => state.items);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

// Component re-renders only when loading changes
function LoadingIndicator() {
  const loading = useDomainStore((state) => state.loading);

  if (!loading) return null;
  return <Spinner />;
}
useShallow for Multiple Values
import { useShallow } from 'zustand/react/shallow';

// BAD: Re-renders when ANY store property changes
function Component() {
  const { items, loading, error } = useDomainStore();
  // ...
}

// GOOD: Re-renders only when items, loading, or error change
function Component() {
  const { items, loading, error } = useDomainStore(
    useShallow((state) => ({
      items: state.items,
      loading: state.loading,
      error: state.error,
    }))
  );
  // ...
}
Custom Selector Hooks
// quikadmin-web/src/stores/domainStore.ts

// Export selector hooks for common patterns
export const useDomainItems = () =>
  useDomainStore((state) => state.items);

export const useDomainLoading = () =>
  useDomainStore((state) => state.loading);

export const useDomainError = () =>
  useDomainStore((state) => state.error);

export const useDomainActions = () =>
  useDomainStore(
    useShallow((state) => ({
      fetchItems: state.fetchItems,
      createItem: state.createItem,
      updateItem: state.updateItem,
      deleteItem: state.deleteItem,
    }))
  );

// Usage in components
function ItemList() {
  const items = useDomainItems();
  const { createItem, deleteItem } = useDomainActions();

  // Component only re-renders when items or actions change
}
Derived State
export const useDomainStore = create<DomainState>()(
  immer((set, get) => ({
    items: [],
    filter: '',

    // Computed/derived state
    get filteredItems() {
      const { items, filter } = get();
      if (!filter) return items;
      return items.filter((item) =>
        item.name.toLowerCase().includes(filter.toLowerCase())
      );
    },

    setFilter: (filter: string) => {
      set({ filter });
    },
  }))
);

// Usage
function FilteredList() {
  const filteredItems = useDomainStore((state) => state.filteredItems);
  return <ul>{filteredItems.map(...)}</ul>;
}

Async Actions

Handle async operations with proper loading and error states.

Async Action Pattern
export const useDomainStore = create<DomainState>()(
  immer((set, get) => ({
    items: [],
    loading: false,
    error: null,

    fetchItems: async () => {
      // Set loading state
      set({ loading: true, error: null });

      try {
        // API call
        const response = await api.get('/domain');

        // Update state on success
        set({
          items: response.data,
          loading: false,
        });
      } catch (error) {
        // Handle error
        set({
          error: error instanceof Error ? error.message : 'Unknown error',
          loading: false,
        });

        // Optionally rethrow for component handling
        throw error;
      }
    },
  }))
);
Optimistic Updates
updateItem: async (id: string, data: Partial<Item>) => {
  // Save original state for rollback
  const originalItems = get().items;

  // Optimistic update
  set((state) => {
    const item = state.items.find((i) => i.id === id);
    if (item) {
      Object.assign(item, data);
    }
  });

  try {
    // API call
    const response = await api.patch(`/domain/${id}`, data);

    // Update with server response
    set((state) => {
      const item = state.items.find((i) => i.id === id);
      if (item) {
        Object.assign(item, response.data);
      }
    });
  } catch (error) {
    // Rollback on error
    set({ items: originalItems, error: 'Failed to update item' });
    throw error;
  }
},
Debounced Actions
import { debounce } from 'lodash-es';

export const useDomainStore = create<DomainState>()(
  immer((set, get) => ({
    searchQuery: '',
    searchResults: [],

    // Create debounced function outside of the store
    searchItems: debounce(async (query: string) => {
      if (!query) {
        set({ searchResults: [] });
        return;
      }

      set({ loading: true });
      try {
        const response = await api.get('/domain/search', { params: { q: query } });
        set({ searchResults: response.data, loading: false });
      } catch (error) {
        set({ error: 'Search failed', loading: false });
      }
    }, 300),

    setSearchQuery: (query: string) => {
      set({ searchQuery: query });
      get().searchItems(query); // Trigger debounced search
    },
  }))
);

Persistence

Configure localStorage persistence for user preferences.

Persist Configuration
import { persist, createJSONStorage } from 'zustand/middleware';

export const usePreferencesStore = create<PreferencesState>()(
  persist(
    immer((set) => ({
      theme: 'light',
      language: 'en',
      sidebarCollapsed: false,

      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleSidebar: () =>
        set((state) => {
          state.sidebarCollapsed = !state.sidebarCollapsed;
        }),
    })),
    {
      name: 'user-preferences',
      storage: createJSONStorage(() => localStorage),

      // Only persist specific fields
      partialPersist: {
        theme: true,
        language: true,
        sidebarCollapsed: true,
      },
    }
  )
);
Session Storage
import { createJSONStorage } from 'zustand/middleware';

export const useSessionStore = create<SessionState>()(
  persist(
    immer((set) => ({
      // Session-only data
    })),
    {
      name: 'session-data',
      storage: createJSONStorage(() => sessionStorage), // Use sessionStorage
    }
  )
);
Migration Example
persist(
  immer((set) => ({
    // Store implementation
  })),
  {
    name: 'domain-storage',
    version: 2, // Current version

    migrate: (persistedState: any, version: number) => {
      // Migrate from v0 to v1
      if (version === 0) {
        persistedState.newField = 'default';
      }

      // Migrate from v1 to v2
      if (version === 1) {
        persistedState.renamedField = persistedState.oldField;
        delete persistedState.oldField;
      }

      return persistedState;
    },
  }
);

Testing Stores

Test stores in isolation with mocked dependencies.

Basic Store Test
// quikadmin-web/src/stores/__tests__/domainStore.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDomainStore } from '../domainStore';
import * as api from '@/services/api';

// Mock API
vi.mock('@/services/api', () => ({
  api: {
    get: vi.fn(),
    post: vi.fn(),
    patch: vi.fn(),
    delete: vi.fn(),
  },
}));

describe('useDomainStore', () => {
  beforeEach(() => {
    // Reset store state before each test
    useDomainStore.setState({
      items: [],
      loading: false,
      error: null,
    });
    vi.clearAllMocks();
  });

  it('fetches items successfully', async () => {
    const mockItems = [
      { id: '1', name: 'Item 1' },
      { id: '2', name: 'Item 2' },
    ];

    vi.mocked(api.api.get).mockResolvedValue({ data: mockItems });

    const { result } = renderHook(() => useDomainStore());

    await act(async () => {
      await result.current.fetchItems();
    });

    expect(result.current.items).toEqual(mockItems);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('handles fetch error', async () => {
    vi.mocked(api.api.get).mockRejectedValue(new Error('Network error'));

    const { result } = renderHook(() => useDomainStore());

    await act(async () => {
      try {
        await result.current.fetchItems();
      } catch (error) {
        // Expected to throw
      }
    });

    expect(result.current.items).toEqual([]);
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeTruthy();
  });

  it('creates item', async () => {
    const newItem = { id: '1', name: 'New Item' };
    vi.mocked(api.api.post).mockResolvedValue({ data: newItem });

    const { result } = renderHook(() => useDomainStore());

    await act(async () => {
      await result.current.createItem({ name: 'New Item' });
    });

    expect(result.current.items).toContainEqual(newItem);
  });
});
Testing Selectors
it('returns filtered items', () => {
  const { result } = renderHook(() => useDomainStore());

  act(() => {
    useDomainStore.setState({
      items: [
        { id: '1', name: 'Apple' },
        { id: '2', name: 'Banana' },
        { id: '3', name: 'Apricot' },
      ],
      filter: 'ap',
    });
  });

  expect(result.current.filteredItems).toHaveLength(2);
  expect(result.current.filteredItems[0].name).toBe('Apple');
  expect(result.current.filteredItems[1].name).toBe('Apricot');
});

Best Practices

  1. Use TypeScript interfaces - Type all store state and actions
  2. Middleware order matters - devtools → persist → immer
  3. Use immer for nested updates - Simplifies complex state updates
  4. Select only what you need - Use selectors to prevent unnecessary re-renders
  5. Use useShallow for objects - Shallow comparison for multiple values
  6. Handle loading and errors - Always track async operation states
  7. Optimize with derived state - Use getters for computed values
  8. Test stores in isolation - Mock API calls and dependencies
  9. Persist user preferences only - Don't persist transient data
  10. Use action naming - Name actions for Redux DevTools

Common Patterns

Store Composition
// Combine multiple stores in a component
function Component() {
  const documents = useDocumentStore((state) => state.documents);
  const templates = useTemplateStore((state) => state.templates);
  const { user } = useAuthStore();

  // Use data from multiple stores
}
Store Subscription
import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    // Subscribe to store changes
    const unsubscribe = useDomainStore.subscribe(
      (state) => state.items,
      (items) => {
        console.log('Items changed:', items);
      }
    );

    return unsubscribe;
  }, []);
}
Reset Store
export const useDomainStore = create<DomainState>()(
  immer((set) => ({
    items: [],
    loading: false,

    reset: () => {
      set({
        items: [],
        loading: false,
        error: null,
      });
    },
  }))
);

References

使用示例

查看来源仓库中的使用示例与配置说明。

查看来源 ↗

来源历史

收录时间
2026-01-20
索引更新时间
2026-03-03
Branch
main

收录与同步日期属于本站记录。源码修改历史请查看来源仓库。

GitHub ↗

chat Comments (0)

chat_bubble_outline

No comments yet. Be the first to share your thoughts!