zustand-state-builder
Zustand State Builder
Build lightweight, scalable state management with Zustand's minimal API.
Core Workflow
- Identify state needs: Determine what needs global state
- Create store: Define state shape and actions
- Add TypeScript types: Full type safety
- Enable middleware: Devtools, persist, immer
- Split stores: Modular slices for large apps
- Connect components: Use hooks to access state
Installation
npm install zustand
# Optional middleware
npm install immer # For immutable updates
Basic Store
Simple Counter Store
// stores/counter.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementBy: (amount: number) => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));
// Usage in component
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
Async Actions
// stores/users.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
}
interface UsersState {
users: User[];
isLoading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
addUser: (user: Omit<User, 'id'>) => Promise<void>;
deleteUser: (id: string) => Promise<void>;
}
export const useUsersStore = create<UsersState>((set, get) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/users');
const users = await response.json();
set({ users, isLoading: false });
} catch (error) {
set({ error: 'Failed to fetch users', isLoading: false });
}
},
addUser: async (userData) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData),
});
const newUser = await response.json();
set((state) => ({
users: [...state.users, newUser],
isLoading: false,
}));
} catch (error) {
set({ error: 'Failed to add user', isLoading: false });
}
},
deleteUser: async (id) => {
const previousUsers = get().users;
// Optimistic update
set((state) => ({
users: state.users.filter((u) => u.id !== id),
}));
try {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
} catch (error) {
// Rollback on error
set({ users: previousUsers, error: 'Failed to delete user' });
}
},
}));
Middleware
DevTools Integration
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface StoreState {
count: number;
increment: () => void;
}
export const useStore = create<StoreState>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
false,
'increment' // Action name for devtools
),
}),
{ name: 'CounterStore' } // Store name in devtools
)
);
Persistence
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
toggleNotifications: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({ notifications: !state.notifications })),
}),
{
name: 'settings-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// Only persist these fields
theme: state.theme,
language: state.language,
notifications: state.notifications,
}),
}
)
);
Immer Middleware
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodosState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
updateTodo: (id: string, text: string) => void;
deleteTodo: (id: string) => void;
}
export const useTodosStore = create<TodosState>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
updateTodo: (id, text) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.text = text;
}
}),
deleteTodo: (id) =>
set((state) => {
const index = state.todos.findIndex((t) => t.id === id);
if (index !== -1) {
state.todos.splice(index, 1);
}
}),
}))
);
Combined Middleware
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => ({
// ... state and actions
})),
{ name: 'store' }
),
{ name: 'MyStore' }
)
);
Slices Pattern
Modular Store Architecture
// stores/slices/authSlice.ts
import { StateCreator } from 'zustand';
export interface AuthSlice {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const createAuthSlice: StateCreator<
AuthSlice & CartSlice, // Combined state type
[],
[],
AuthSlice
> = (set) => ({
user: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const user = await response.json();
set({ user, isAuthenticated: true });
},
logout: () => set({ user: null, isAuthenticated: false }),
});
// stores/slices/cartSlice.ts
import { StateCreator } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export interface CartSlice {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
totalItems: () => number;
totalPrice: () => number;
}
export const createCartSlice: StateCreator<
AuthSlice & CartSlice,
[],
[],
CartSlice
> = (set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
});
// stores/index.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createAuthSlice, AuthSlice } from './slices/authSlice';
import { createCartSlice, CartSlice } from './slices/cartSlice';
type StoreState = AuthSlice & CartSlice;
export const useStore = create<StoreState>()(
devtools(
persist(
(...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
}),
{
name: 'app-store',
partialize: (state) => ({
items: state.items, // Persist cart
// Don't persist auth (handle with tokens)
}),
}
),
{ name: 'AppStore' }
)
);
Selectors
Optimized Selectors
// Avoid re-renders with selectors
function UserName() {
// Only re-renders when user.name changes
const userName = useStore((state) => state.user?.name);
return <span>{userName}</span>;
}
// Multiple values with shallow comparison
import { shallow } from 'zustand/shallow';
function UserInfo() {
const { name, email } = useStore(
(state) => ({ name: state.user?.name, email: state.user?.email }),
shallow
);
return (
<div>
<p>{name}</p>
<p>{email}</p>
</div>
);
}
// Computed values
function CartSummary() {
const totalItems = useStore((state) =>
state.items.reduce((sum, i) => sum + i.quantity, 0)
);
const totalPrice = useStore((state) =>
state.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
return (
<div>
<p>Items: {totalItems}</p>
<p>Total: ${totalPrice.toFixed(2)}</p>
</div>
);
}
Reusable Selector Hooks
// stores/selectors.ts
import { useStore } from './index';
import { shallow } from 'zustand/shallow';
// Auth selectors
export const useAuth = () =>
useStore(
(state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
login: state.login,
logout: state.logout,
}),
shallow
);
export const useUser = () => useStore((state) => state.user);
export const useIsAuthenticated = () => useStore((state) => state.isAuthenticated);
// Cart selectors
export const useCart = () =>
useStore(
(state) => ({
items: state.items,
addItem: state.addItem,
removeItem: state.removeItem,
updateQuantity: state.updateQuantity,
clearCart: state.clearCart,
}),
shallow
);
export const useCartTotal = () =>
useStore((state) => ({
items: state.items.reduce((sum, i) => sum + i.quantity, 0),
price: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}), shallow);
Outside React Usage
// Access store outside React components
const { getState, setState, subscribe } = useStore;
// Get current state
const currentUser = useStore.getState().user;
// Update state
useStore.setState({ user: newUser });
// Subscribe to changes
const unsubscribe = useStore.subscribe((state) => {
console.log('State changed:', state);
});
// Subscribe to specific slice
const unsubscribeCart = useStore.subscribe(
(state) => state.items,
(items, previousItems) => {
console.log('Cart changed:', items);
}
);
Server State Integration
With TanStack Query
// stores/ui.ts - Client state only
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
modalOpen: boolean;
toggleSidebar: () => void;
openModal: () => void;
closeModal: () => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
modalOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
openModal: () => set({ modalOpen: true }),
closeModal: () => set({ modalOpen: false }),
}));
// hooks/useUsers.ts - Server state with TanStack Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: CreateUserDto) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Testing
// stores/__tests__/counter.test.ts
import { act, renderHook } from '@testing-library/react';
import { useCounterStore } from '../counter';
describe('Counter Store', () => {
beforeEach(() => {
// Reset store before each test
useCounterStore.setState({ count: 0 });
});
it('increments count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
useCounterStore.setState({ count: 5 });
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets count', () => {
useCounterStore.setState({ count: 10 });
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
Best Practices
- Keep stores small: One store per domain
- Use selectors: Prevent unnecessary re-renders
- Separate client/server state: Use TanStack Query for server state
- Enable devtools: Essential for debugging
- Type everything: Full TypeScript coverage
- Use immer for nested state: Cleaner immutable updates
- Persist sparingly: Only persist what's needed
- Test stores: Unit test actions and state changes
Output Checklist
Every Zustand store should include:
- TypeScript interfaces for state and actions
- Devtools middleware enabled
- Persistence where needed
- Selectors for optimized re-renders
- Slices pattern for large stores
- Async action error handling
- Outside React access method
- Unit tests for actions
- Integration with server state library
More from monkey1sai/openai-cli
multi-tenant-safety-checker
Ensures tenant isolation at query and policy level using Row Level Security, automated testing, and security audits. Prevents data leakage between tenants. Use for "multi-tenancy", "tenant isolation", "RLS", or "data security".
10modal-drawer-system
Implements accessible modals and drawers with focus trap, ESC to close, scroll lock, portal rendering, and ARIA attributes. Includes sample implementations for common use cases like edit forms, confirmations, and detail views. Use when building "modals", "dialogs", "drawers", "sidebars", or "overlays".
10eslint-prettier-config
Configures ESLint and Prettier for consistent code quality with TypeScript, React, and modern best practices. Use when users request "ESLint setup", "Prettier config", "linting configuration", "code formatting", or "lint rules".
9api-security-hardener
Hardens API security with rate limiting, input validation, authentication, and protection against common attacks. Use when users request "API security", "secure API", "rate limiting", "input validation", or "API protection".
9secure-headers-csp-builder
Implements security headers and Content Security Policy with safe rollout strategy (report-only → enforce), testing, and compatibility checks. Use for "security headers", "CSP", "HTTP headers", or "XSS protection".
9security-incident-playbook-generator
Creates response procedures for security incidents with containment steps, communication templates, and evidence collection. Use for "incident response", "security playbook", "breach response", or "IR plan".
9