install
source · Clone the upstream repo
git clone https://github.com/MacPhobos/research-mind
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/MacPhobos/research-mind "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/toolchains-typescript-state-zustand" ~/.claude/skills/macphobos-research-mind-toolchains-typescript-state-zustand && rm -rf "$T"
manifest:
.claude/skills/toolchains-typescript-state-zustand/skill.mdsource content
Zustand State Management
Summary
Zustand is a minimal, unopinionated state management library for React. No providers, no boilerplate—just a simple hook-based API that feels natural in React applications.
When to Use
- React apps needing global state without Redux complexity
- Projects wanting minimal boilerplate and bundle size
- Teams preferring direct state mutations over reducers
- SSR applications (Next.js) requiring flexible state hydration
- Migrating from Redux/Context API to simpler solution
Quick Start
npm install zustand
// stores/useCounterStore.ts import { create } from 'zustand' interface CounterState { count: number increment: () => void decrement: () => void } export const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), })) // components/Counter.tsx import { useCounterStore } from '@/stores/useCounterStore' export function Counter() { const { count, increment, decrement } = useCounterStore() return ( <div> <p>Count: {count}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ) }
Complete Zustand Guide
Core Concepts
Store Creation
import { create } from 'zustand' // Basic store interface BearState { bears: number addBear: () => void } const useBearStore = create<BearState>((set) => ({ bears: 0, addBear: () => set((state) => ({ bears: state.bears + 1 })), })) // Store with get access const useStore = create<State>((set, get) => ({ count: 0, increment: () => { const currentCount = get().count set({ count: currentCount + 1 }) }, }))
State Access Patterns
// Select entire store (re-renders on any change) const state = useStore() // Select specific fields (re-renders only when these change) const bears = useStore((state) => state.bears) const addBear = useStore((state) => state.addBear) // Destructure with selector const { bears, addBear } = useStore((state) => ({ bears: state.bears, addBear: state.addBear, })) // Multiple selectors const bears = useStore((state) => state.bears) const fish = useStore((state) => state.fish)
Mutations
interface TodoState { todos: Todo[] addTodo: (text: string) => void toggleTodo: (id: string) => void removeTodo: (id: string) => void } const useTodoStore = create<TodoState>((set) => ({ todos: [], // Add item addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: nanoid(), text, completed: false }] })), // Update item toggleTodo: (id) => set((state) => ({ todos: state.todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) })), // Remove item removeTodo: (id) => set((state) => ({ todos: state.todos.filter(todo => todo.id !== id) })), }))
React Integration
useStore Hook
function BearCounter() { // Re-renders when bears changes const bears = useBearStore((state) => state.bears) return <h1>{bears} bears around here...</h1> } function Controls() { // Doesn't re-render when bears changes const addBear = useBearStore((state) => state.addBear) return <button onClick={addBear}>Add bear</button> }
Shallow Comparison
import { shallow } from 'zustand/shallow' // Prevent re-renders when object identity changes but values don't const { nuts, honey } = useBearStore( (state) => ({ nuts: state.nuts, honey: state.honey }), shallow ) // Custom equality function const treats = useBearStore( (state) => state.treats, (prev, next) => prev.length === next.length )
Outside React Components
// Read state const count = useStore.getState().count // Subscribe to changes const unsubscribe = useStore.subscribe( (state) => console.log('Count changed:', state.count) ) // Update state useStore.setState({ count: 42 }) // Update with function useStore.setState((state) => ({ count: state.count + 1 }))
TypeScript Patterns
Typed Store Creation
interface UserState { user: User | null setUser: (user: User) => void clearUser: () => void } const useUserStore = create<UserState>((set) => ({ user: null, setUser: (user) => set({ user }), clearUser: () => set({ user: null }), })) // Type inference works automatically const user = useUserStore((state) => state.user) // User | null
Store Type Inference
// Extract store type type UserStoreState = ReturnType<typeof useUserStore.getState> // Selector type helper type Selector<T> = (state: UserState) => T const selectUsername: Selector<string | undefined> = (state) => state.user?.name
Combining Multiple Stores
// Type-safe store combination function useHybridStore<T, U>( selector1: (state: State1) => T, selector2: (state: State2) => U ): [T, U] { return [ useStore1(selector1), useStore2(selector2), ] } const [user, theme] = useHybridStore( (s) => s.user, (s) => s.theme )
Slices Pattern
Creating Slices
// authSlice.ts export interface AuthSlice { user: User | null login: (credentials: Credentials) => Promise<void> logout: () => void } export const createAuthSlice: StateCreator< AuthSlice & TodoSlice, [], [], AuthSlice > = (set) => ({ user: null, login: async (credentials) => { const user = await api.login(credentials) set({ user }) }, logout: () => set({ user: null }), }) // todoSlice.ts export interface TodoSlice { todos: Todo[] addTodo: (text: string) => void } export const createTodoSlice: StateCreator< AuthSlice & TodoSlice, [], [], TodoSlice > = (set) => ({ todos: [], addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: nanoid(), text, completed: false }] })), }) // store.ts import { create } from 'zustand' import { createAuthSlice, AuthSlice } from './authSlice' import { createTodoSlice, TodoSlice } from './todoSlice' export const useStore = create<AuthSlice & TodoSlice>()((...a) => ({ ...createAuthSlice(...a), ...createTodoSlice(...a), }))
Cross-Slice Communication
export const createTodoSlice: StateCreator< AuthSlice & TodoSlice, [], [], TodoSlice > = (set, get) => ({ todos: [], addTodo: (text) => { // Access other slice's state const user = get().user if (!user) throw new Error('Not authenticated') set((state) => ({ todos: [...state.todos, { id: nanoid(), text, userId: user.id, completed: false }] })) }, })
Middleware
Persist Middleware
import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' interface PreferencesState { theme: 'light' | 'dark' language: string setTheme: (theme: 'light' | 'dark') => void } export const usePreferencesStore = create<PreferencesState>()( persist( (set) => ({ theme: 'light', language: 'en', setTheme: (theme) => set({ theme }), }), { name: 'preferences-storage', // localStorage key storage: createJSONStorage(() => localStorage), // Partial persistence partialize: (state) => ({ theme: state.theme }), // Migration between versions version: 1, migrate: (persistedState: any, version: number) => { if (version === 0) { // Migrate from v0 to v1 persistedState.language = 'en' } return persistedState as PreferencesState }, } ) ) // Custom storage (e.g., AsyncStorage for React Native) const customStorage = { getItem: async (name: string) => { const value = await AsyncStorage.getItem(name) return value ?? null }, setItem: async (name: string, value: string) => { await AsyncStorage.setItem(name, value) }, removeItem: async (name: string) => { await AsyncStorage.removeItem(name) }, } const useStore = create( persist( (set) => ({ /* ... */ }), { name: 'app-storage', storage: createJSONStorage(() => customStorage) } ) )
DevTools Middleware
import { devtools } from 'zustand/middleware' interface CounterState { count: number increment: () => void } const useCounterStore = create<CounterState>()( devtools( (set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'), }), { name: 'CounterStore', enabled: process.env.NODE_ENV === 'development' } ) ) // Action names in Redux DevTools set({ count: 42 }, false, 'setCount') set((state) => ({ count: state.count + 1 }), false, { type: 'increment', amount: 1 })
Immer Middleware
import { immer } from 'zustand/middleware/immer' interface TodoState { todos: Todo[] addTodo: (text: string) => void toggleTodo: (id: string) => void } const useTodoStore = create<TodoState>()( immer((set) => ({ todos: [], // Mutate state directly with Immer addTodo: (text) => set((state) => { state.todos.push({ id: nanoid(), text, completed: false }) }), toggleTodo: (id) => set((state) => { const todo = state.todos.find(t => t.id === id) if (todo) todo.completed = !todo.completed }), })) )
Combining Middleware
const useStore = create<State>()( devtools( persist( immer((set) => ({ // Store implementation })), { name: 'app-storage' } ), { name: 'AppStore' } ) )
Async Actions & API Integration
Basic Async Actions
interface UserState { users: User[] loading: boolean error: string | null fetchUsers: () => Promise<void> } const useUserStore = create<UserState>((set) => ({ users: [], loading: false, error: null, fetchUsers: async () => { set({ loading: true, error: null }) try { const users = await api.getUsers() set({ users, loading: false }) } catch (error) { set({ error: error.message, loading: false }) } }, }))
Optimistic Updates
interface TodoState { todos: Todo[] addTodo: (text: string) => Promise<void> } const useTodoStore = create<TodoState>((set, get) => ({ todos: [], addTodo: async (text) => { const tempId = `temp-${Date.now()}` const optimisticTodo = { id: tempId, text, completed: false } // Add optimistically set((state) => ({ todos: [...state.todos, optimisticTodo] })) try { const savedTodo = await api.createTodo(text) // Replace temp with real todo set((state) => ({ todos: state.todos.map(t => t.id === tempId ? savedTodo : t ) })) } catch (error) { // Rollback on error set((state) => ({ todos: state.todos.filter(t => t.id !== tempId) })) throw error } }, }))
Request Deduplication
interface DataState { data: Data | null loading: boolean fetchData: () => Promise<void> } let currentRequest: Promise<void> | null = null const useDataStore = create<DataState>((set) => ({ data: null, loading: false, fetchData: async () => { // Return existing request if in progress if (currentRequest) return currentRequest set({ loading: true }) currentRequest = api.getData() .then((data) => { set({ data, loading: false }) }) .catch((error) => { set({ loading: false }) throw error }) .finally(() => { currentRequest = null }) return currentRequest }, }))
Computed Values (Selectors)
Basic Selectors
interface TodoState { todos: Todo[] } // Memoized with useCallback or outside component const selectCompletedCount = (state: TodoState) => state.todos.filter(t => t.completed).length const selectActiveCount = (state: TodoState) => state.todos.filter(t => !t.completed).length function TodoStats() { const completedCount = useTodoStore(selectCompletedCount) const activeCount = useTodoStore(selectActiveCount) return <div>{completedCount} / {activeCount + completedCount}</div> }
Derived State in Store
interface TodoState { todos: Todo[] get completed(): Todo[] get active(): Todo[] get stats(): { total: number; completed: number; active: number } } const useTodoStore = create<TodoState>((set, get) => ({ todos: [], get completed() { return get().todos.filter(t => t.completed) }, get active() { return get().todos.filter(t => !t.completed) }, get stats() { const todos = get().todos return { total: todos.length, completed: todos.filter(t => t.completed).length, active: todos.filter(t => !t.completed).length, } }, })) // Usage const stats = useTodoStore((state) => state.stats)
Parameterized Selectors
// Create selector factory const selectTodoById = (id: string) => (state: TodoState) => state.todos.find(t => t.id === id) function TodoItem({ id }: { id: string }) { const todo = useTodoStore(selectTodoById(id)) return <div>{todo?.text}</div> }
Performance Optimization
Subscription Patterns
// Subscribe to specific state changes useEffect(() => { const unsubscribe = useTodoStore.subscribe( (state) => state.todos, (todos) => { console.log('Todos changed:', todos) } ) return unsubscribe }, []) // Subscribe with selector and equality const unsubscribe = useTodoStore.subscribe( (state) => state.todos.length, (length) => console.log('Todo count:', length), { equalityFn: (a, b) => a === b } )
Transient Updates
// Updates that don't trigger subscribers interface ScrubbingState { position: number updatePosition: (pos: number) => void } const useScrubbingStore = create<ScrubbingState>((set) => ({ position: 0, updatePosition: (pos) => set({ position: pos }, true), // true = transient })) // Subscribers won't be notified useScrubbingStore.getState().updatePosition(50)
Batching Updates
const useTodoStore = create<TodoState>((set) => ({ todos: [], batchUpdate: (updates: Partial<TodoState>[]) => { // Single re-render for multiple updates set((state) => { let newState = { ...state } updates.forEach(update => { newState = { ...newState, ...update } }) return newState }) }, }))
Testing Strategies
Mock Stores
// __tests__/Counter.test.tsx import { create } from 'zustand' import { render, screen, fireEvent } from '@testing-library/react' import { Counter } from '@/components/Counter' import { useCounterStore } from '@/stores/useCounterStore' // Mock the store jest.mock('@/stores/useCounterStore') describe('Counter', () => { beforeEach(() => { const mockStore = create<CounterState>((set) => ({ count: 0, increment: jest.fn(() => set((state) => ({ count: state.count + 1 }))), decrement: jest.fn(), })) useCounterStore.mockImplementation(mockStore) }) it('increments count', () => { render(<Counter />) fireEvent.click(screen.getByText('+')) expect(screen.getByText('Count: 1')).toBeInTheDocument() }) })
Test Utilities
// test-utils.ts import { create } from 'zustand' export function createTestStore<T>(initialState: Partial<T>) { return create<T>(() => initialState as T) } // Usage in tests const testStore = createTestStore<TodoState>({ todos: [ { id: '1', text: 'Test todo', completed: false } ] })
Reset Store Between Tests
// stores/useCounterStore.ts const initialState = { count: 0 } export const useCounterStore = create<CounterState>((set) => ({ ...initialState, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set(initialState), })) // __tests__/Counter.test.tsx afterEach(() => { useCounterStore.getState().reset() })
Migration Guides
From Redux
// Redux const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1 }, decrement: (state) => { state.value -= 1 }, }, }) // Zustand equivalent const useCounterStore = create<CounterState>((set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), decrement: () => set((state) => ({ value: state.value - 1 })), })) // Redux usage const dispatch = useDispatch() const value = useSelector((state) => state.counter.value) dispatch(increment()) // Zustand usage const { value, increment } = useCounterStore() increment()
From Context API
// Context API const ThemeContext = createContext<ThemeContextType>(null!) export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, setTheme }}> {children} </ThemeContext.Provider> ) } export const useTheme = () => useContext(ThemeContext) // Zustand equivalent (no provider needed!) export const useThemeStore = create<ThemeState>((set) => ({ theme: 'light', setTheme: (theme) => set({ theme }), })) // Usage is simpler const { theme, setTheme } = useThemeStore()
Next.js Integration
App Router (RSC)
// stores/useCartStore.ts import { create } from 'zustand' import { persist } from 'zustand/middleware' export const useCartStore = create<CartState>()( persist( (set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), }), { name: 'cart-storage', // Skip persistence on server skipHydration: true, } ) ) // components/Cart.tsx (Client Component) 'use client' import { useCartStore } from '@/stores/useCartStore' import { useEffect } from 'react' export function Cart() { const { items, addItem } = useCartStore() // Hydrate persisted state useEffect(() => { useCartStore.persist.rehydrate() }, []) return <div>{items.length} items</div> }
Server Actions Integration
// actions/cart.ts 'use server' import { revalidatePath } from 'next/cache' export async function syncCartToServer(items: CartItem[]) { await db.cart.upsert({ where: { userId: 'current-user' }, update: { items }, create: { userId: 'current-user', items }, }) revalidatePath('/cart') } // stores/useCartStore.ts export const useCartStore = create<CartState>((set) => ({ items: [], addItem: async (item) => { set((state) => ({ items: [...state.items, item] })) // Sync to server const items = useCartStore.getState().items await syncCartToServer(items) }, }))
SSR Hydration
// app/layout.tsx import { CartStoreProvider } from '@/providers/CartStoreProvider' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html> <body> <CartStoreProvider> {children} </CartStoreProvider> </body> </html> ) } // providers/CartStoreProvider.tsx 'use client' import { useRef } from 'react' import { useCartStore } from '@/stores/useCartStore' export function CartStoreProvider({ children }: { children: ReactNode }) { const initialized = useRef(false) if (!initialized.current) { // Initialize with server data if needed useCartStore.setState({ items: [] }) initialized.current = true } return <>{children}</> }
Best Practices
Store Organization
// ✅ Good: Single responsibility stores const useAuthStore = create<AuthState>(...) const useTodoStore = create<TodoState>(...) const useUIStore = create<UIState>(...) // ❌ Bad: God store const useAppStore = create<AppState>(...)
Action Naming
// ✅ Good: Clear, verb-based actions const useStore = create((set) => ({ addTodo: (text) => set(...), removeTodo: (id) => set(...), toggleTodo: (id) => set(...), })) // ❌ Bad: Vague or noun-based const useStore = create((set) => ({ todo: (text) => set(...), // What does this do? update: (id) => set(...), // Update what? }))
Selector Optimization
// ✅ Good: Specific selectors const user = useStore((state) => state.user) const theme = useStore((state) => state.theme) // ❌ Bad: Selecting entire store const state = useStore() // Re-renders on any change
Error Handling
// ✅ Good: Explicit error state interface State { data: Data | null loading: boolean error: Error | null fetchData: () => Promise<void> } // ❌ Bad: Silent failures const fetchData = async () => { try { const data = await api.getData() set({ data }) } catch (error) { // Error silently ignored } }
Common Patterns
Loading States
interface ResourceState<T> { data: T | null loading: boolean error: Error | null status: 'idle' | 'loading' | 'success' | 'error' } function createResourceStore<T>() { return create<ResourceState<T>>((set) => ({ data: null, loading: false, error: null, status: 'idle', fetch: async () => { set({ loading: true, status: 'loading', error: null }) try { const data = await fetchData() set({ data, loading: false, status: 'success' }) } catch (error) { set({ error, loading: false, status: 'error' }) } }, })) }
Undo/Redo
interface HistoryState<T> { past: T[] present: T future: T[] set: (state: T) => void undo: () => void redo: () => void } function createHistoryStore<T>(initialState: T) { return create<HistoryState<T>>((set) => ({ past: [], present: initialState, future: [], set: (newPresent) => set((state) => ({ past: [...state.past, state.present], present: newPresent, future: [], })), undo: () => set((state) => { if (state.past.length === 0) return state const previous = state.past[state.past.length - 1] const newPast = state.past.slice(0, -1) return { past: newPast, present: previous, future: [state.present, ...state.future], } }), redo: () => set((state) => { if (state.future.length === 0) return state const next = state.future[0] const newFuture = state.future.slice(1) return { past: [...state.past, state.present], present: next, future: newFuture, } }), })) }
Comparison with Alternatives
vs Redux
Zustand Advantages:
- No boilerplate (no actions, reducers, dispatch)
- No provider needed
- Smaller bundle size (~1kb vs ~20kb)
- Simpler async handling
- TypeScript inference works out of the box
Redux Advantages:
- Time-travel debugging
- Larger ecosystem and middleware
- Strict unidirectional data flow
- Better for very large applications
vs Context API
Zustand Advantages:
- No provider hell
- Better performance (no re-render entire subtree)
- Simpler API
- Built-in middleware
Context Advantages:
- Built into React (no dependency)
- Better for component-local state
- Explicit component boundaries
vs Jotai
Zustand Advantages:
- More traditional store-based approach
- Better for complex state logic
- Easier migration from Redux
Jotai Advantages:
- Atomic state management
- Better code splitting
- More React-like (atom-based)
- Suspense support out of the box
Resources
Related Skills
When using Zustand, these skills enhance your workflow:
- react: React integration patterns and hooks for Zustand stores
- tanstack-query: Server-state management (use with Zustand for client state)
- nextjs: Zustand with Next.js App Router and Client Components
- test-driven-development: Testing Zustand stores, actions, and selectors
[Full documentation available in these skills if deployed in your bundle]