Skip to main content
The Zustand skill provides expert knowledge for client-side state management using the decoupled actions pattern. Learn how to create stores with proper TypeScript types, atomic selectors, and tree-shakeable actions.

Overview

This skill teaches:
  • Decoupled actions - Export actions as plain functions, not in the store
  • Atomic selectors - Select minimal state to prevent unnecessary re-renders
  • Client-side only - Store modules must only be imported from Client Components
  • useState vs Zustand - When to use each approach
Zustand stores are client-side only. Never import store modules from Server Components.

Quick Start

npm install zustand
# or
bun add zustand

Core Principles

1. Client-Side Only

// app/page.tsx (Server Component)
import { useCounterStore } from "@/store/use-counter-store"
// ❌ This will fail - Server Components cannot use client state

export default function Page() {
  return <div>...</div>
}

2. State Only in Store

The create() call should define the state shape and initial values only. Actions are defined separately.
export const useStore = create<State & Actions>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

3. Atomic Selectors

Select the smallest slice of state needed to minimize re-renders.
const state = useStore()
// Re-renders on ANY state change

The Decoupled Actions Pattern

Why This Pattern?

  • No hook for actions - Components import actions directly, avoiding unnecessary subscriptions
  • Testable - Actions are plain functions that can be tested in isolation
  • Tree-shakeable - Unused actions are eliminated from the bundle

Full Example

// web/store/use-todo-store.ts
import { create } from "zustand"
import { subscribeWithSelector } from "zustand/middleware"

interface Todo {
  id: string
  text: string
  completed: boolean
}

interface TodoState {
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const initialState: TodoState = {
  todos: [],
  filter: 'all'
}

export const useTodoStore = create<TodoState>()()
  subscribeWithSelector(() => initialState)
)

// Actions
export const addTodo = (text: string) => {
  useTodoStore.setState((state) => ({
    todos: [
      ...state.todos,
      { id: crypto.randomUUID(), text, completed: false }
    ]
  }))
}

export const toggleTodo = (id: string) => {
  useTodoStore.setState((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  }))
}

export const deleteTodo = (id: string) => {
  useTodoStore.setState((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id)
  }))
}

export const setFilter = (filter: TodoState['filter']) => {
  useTodoStore.setState({ filter })
}

export const clearCompleted = () => {
  useTodoStore.setState((state) => ({
    todos: state.todos.filter((todo) => !todo.completed)
  }))
}

export const reset = () => {
  useTodoStore.setState(initialState)
}

Best Practices

Always Use subscribeWithSelector

import { create } from "zustand"
import { subscribeWithSelector } from "zustand/middleware"

export const useMyStore = create<MyStore>()()
  subscribeWithSelector((set, get) => ({
    // state...
  }))
)

Use Functional Updates

When the new value depends on previous state:
export const increment = () => {
  useStore.setState((state) => ({ count: state.count + 1 }))
}

Multiple Selectors

When you need multiple values:
const count = useStore((state) => state.count)
const items = useStore((state) => state.items)

useState vs Zustand

  • State needs to be shared across multiple components
  • State persists between component unmounts
  • Form state that needs to be accessed from multiple places
  • UI state that should survive navigation
  • Complex state with many actions
Default to Zustand for most state, even local UI state. This keeps components presentational and state testable. Only use useState for trivial, ephemeral toggles.

Store Template

Use this template for new stores:
// web/store/use-{{name}}-store.ts
import { create } from "zustand"
import { subscribeWithSelector } from "zustand/middleware"

// ============================================================================
// Types
// ============================================================================

interface {{Name}}State {
  items: unknown[]
  selectedId: string | null
  isLoading: boolean
  error: string | null
}

// ============================================================================
// Initial State
// ============================================================================

const initialState: {{Name}}State = {
  items: [],
  selectedId: null,
  isLoading: false,
  error: null,
}

// ============================================================================
// Store
// ============================================================================

export const use{{Name}}Store = create<{{Name}}State>()()
  subscribeWithSelector(() => initialState)
)

// ============================================================================
// Actions (decoupled)
// ============================================================================

export const setItems = (items: unknown[]) => {
  use{{Name}}Store.setState({ items })
}

export const setSelectedId = (selectedId: string | null) => {
  use{{Name}}Store.setState({ selectedId })
}

export const setLoading = (isLoading: boolean) => {
  use{{Name}}Store.setState({ isLoading })
}

export const setError = (error: string | null) => {
  use{{Name}}Store.setState({ error })
}

export const reset = () => {
  use{{Name}}Store.setState(initialState)
}
See .github/skills/zustand/assets/template.md for the full template.

Quality Checklist

Before completing any Zustand-related task:
  • Stores are only imported in Client Components ('use client')
  • Actions are exported as standalone functions, not inside create()
  • Store uses subscribeWithSelector middleware
  • Components use atomic selectors to minimize re-renders
  • Functional updates are used when new state depends on previous state
  • No destructuring of entire store (e.g., const { a, b, c } = useStore())
  • State interface is properly typed
  • Action parameters have explicit types
  • Run bun run lint to verify no type errors

Skill Structure

.github/skills/zustand/
├── SKILL.md           # This overview
└── assets/
    └── template.md    # Store template

References

Copy the template from .github/skills/zustand/assets/template.md and replace {{Name}} and {{description}} placeholders when creating new stores.

Build docs developers (and LLMs) love