zustand-state-builder
SKILL.md
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
Weekly Installs
11
Repository
patricio0312rev/skillsFirst Seen
10 days ago
Installed on
claude-code8
gemini-cli7
antigravity7
windsurf7
github-copilot7
codex7