Create Zustand stores for IntelliFill with immer, persist, and devtools middleware. Use when adding state management for new features.
This skill provides comprehensive guidance for creating Zustand stores in the IntelliFill frontend (quikadmin-web/).
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
āāā ...
IntelliFill uses a consistent pattern for all stores.
// 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' }
)
);
IntelliFill uses three middleware layers: immer, persist, and devtools.
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 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 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;
},
}
)
);
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
}
)
);
Optimize re-renders with 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 />;
}
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,
}))
);
// ...
}
// 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
}
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>;
}
Handle async operations with proper loading and error states.
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;
}
},
}))
);
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;
}
},
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
},
}))
);
Configure localStorage persistence for user preferences.
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,
},
}
)
);
import { createJSONStorage } from 'zustand/middleware';
export const useSessionStore = create<SessionState>()(
persist(
immer((set) => ({
// Session-only data
})),
{
name: 'session-data',
storage: createJSONStorage(() => sessionStorage), // Use sessionStorage
}
)
);
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;
},
}
);
Test stores in isolation with mocked dependencies.
// 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);
});
});
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');
});
// 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
}
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;
}, []);
}
export const useDomainStore = create<DomainState>()(
immer((set) => ({
items: [],
loading: false,
reset: () => {
set({
items: [],
loading: false,
error: null,
});
},
}))
);