zustand-store
Create Zustand stores for IntelliFill with immer, persist, and devtools middleware. Use when adding state management for new features.
$ Installer
git clone https://github.com/Intellifill/IntelliFill /tmp/IntelliFill && cp -r /tmp/IntelliFill/.claude/skills/zustand-store ~/.claude/skills/IntelliFill// tip: Run this command in your terminal to install the skill
name: zustand-store description: Create Zustand stores for IntelliFill with immer, persist, and devtools middleware. Use when adding state management for new features.
Zustand Store Development Skill
This skill provides comprehensive guidance for creating Zustand stores in the IntelliFill frontend (quikadmin-web/).
Table of Contents
- Store Architecture
- Basic Store Pattern
- Middleware Stack
- Selectors and Hooks
- Async Actions
- Persistence
- Testing Stores
- 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
- Use TypeScript interfaces - Type all store state and actions
- Middleware order matters - devtools â persist â immer
- Use immer for nested updates - Simplifies complex state updates
- Select only what you need - Use selectors to prevent unnecessary re-renders
- Use useShallow for objects - Shallow comparison for multiple values
- Handle loading and errors - Always track async operation states
- Optimize with derived state - Use getters for computed values
- Test stores in isolation - Mock API calls and dependencies
- Persist user preferences only - Don't persist transient data
- 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
Repository
