Documentation/Jutsu/Zustand/ skills /zustand-typescript

📖 zustand-typescript

Use when working with Zustand in TypeScript projects. Covers type-safe store creation, typed selectors, and advanced TypeScript patterns with Zustand.



Overview

Zustand has excellent TypeScript support out of the box. This skill covers type-safe patterns and best practices for using Zustand with TypeScript.

Key Concepts

Basic Type-Safe Store

Define your store interface and use it with create:

import { create } from 'zustand'

interface BearStore {
  bears: number
  increasePopulation: () => void
  removeAllBears: () => void
  updateBears: (newBears: number) => void
}

const useBearStore = create<BearStore>()((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))

Type Inference

Zustand can infer types automatically:

const useStore = create((set) => ({
  count: 0,
  text: '',
  increment: () => set((state) => ({ count: state.count + 1 })),
  setText: (text: string) => set({ text }),
}))

// Types are inferred automatically
type Store = ReturnType<typeof useStore.getState>
// {
//   count: number
//   text: string
//   increment: () => void
//   setText: (text: string) => void
// }

Best Practices

1. Define Store Interfaces

Always define explicit interfaces for better type safety and IDE support:

interface User {
  id: string
  name: string
  email: string
}

interface UserStore {
  // State
  users: User[]
  selectedUserId: string | null
  isLoading: boolean
  error: string | null

  // Computed
  selectedUser: User | null

  // Actions
  fetchUsers: () => Promise<void>
  selectUser: (id: string) => void
  clearSelection: () => void
}

const useUserStore = create<UserStore>()((set, get) => ({
  users: [],
  selectedUserId: null,
  isLoading: false,
  error: null,

  get selectedUser() {
    const { users, selectedUserId } = get()
    return users.find((u) => u.id === selectedUserId) ?? null
  },

  fetchUsers: async () => {
    set({ isLoading: true, error: null })
    try {
      const users = await api.fetchUsers()
      set({ users, isLoading: false })
    } catch (error) {
      set({ error: error.message, isLoading: false })
    }
  },

  selectUser: (id) => set({ selectedUserId: id }),
  clearSelection: () => set({ selectedUserId: null }),
}))

2. Type-Safe Selectors

Create typed selector functions for reusable logic:

interface TodoStore {
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  setFilter: (filter: TodoStore['filter']) => void
}

const useTodoStore = create<TodoStore>()(/* ... */)

// Typed selector functions
const selectFilteredTodos = (state: TodoStore) => {
  if (state.filter === 'all') return state.todos
  if (state.filter === 'active') return state.todos.filter((t) => !t.completed)
  return state.todos.filter((t) => t.completed)
}

const selectActiveTodoCount = (state: TodoStore) =>
  state.todos.filter((t) => !t.completed).length

// Usage
function TodoList() {
  const filteredTodos = useTodoStore(selectFilteredTodos)
  const activeCount = useTodoStore(selectActiveTodoCount)

  return (
    <div>
      <p>{activeCount} active todos</p>
      {filteredTodos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  )
}

3. Slice Pattern with Types

Type-safe store slices for large applications:

import { StateCreator } from 'zustand'

interface BearSlice {
  bears: number
  addBear: () => void
  eatFish: () => void
}

interface FishSlice {
  fishes: number
  addFish: () => void
}

interface SharedSlice {
  addBoth: () => void
  getBoth: () => number
}

const createBearSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  BearSlice
> = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

const createFishSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  FishSlice
> = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

const createSharedSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  SharedSlice
> = (set, get) => ({
  addBoth: () => {
    get().addBear()
    get().addFish()
  },
  getBoth: () => get().bears + get().fishes,
})

const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()(
  (...a) => ({
    ...createBearSlice(...a),
    ...createFishSlice(...a),
    ...createSharedSlice(...a),
  })
)

4. Generic Store Factory

Create reusable store factories with generics:

import { create, StoreApi } from 'zustand'

interface AsyncState<T> {
  data: T | null
  isLoading: boolean
  error: string | null
}

interface AsyncActions<T> {
  fetch: () => Promise<void>
  reset: () => void
}

type AsyncStore<T> = AsyncState<T> & AsyncActions<T>

function createAsyncStore<T>(
  fetcher: () => Promise<T>
): StoreApi<AsyncStore<T>> {
  return create<AsyncStore<T>>()((set) => ({
    data: null,
    isLoading: false,
    error: null,

    fetch: async () => {
      set({ isLoading: true, error: null })
      try {
        const data = await fetcher()
        set({ data, isLoading: false })
      } catch (error) {
        set({
          error: error instanceof Error ? error.message : 'Unknown error',
          isLoading: false,
        })
      }
    },

    reset: () => set({ data: null, isLoading: false, error: null }),
  }))
}

// Usage
interface User {
  id: string
  name: string
}

const useUserStore = createAsyncStore<User[]>(() =>
  fetch('/api/users').then((r) => r.json())
)

5. Type-Safe Middleware

Type middleware correctly for full type safety:

import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
import type { PersistOptions } from 'zustand/middleware'

interface MyStore {
  count: number
  increment: () => void
}

type MyPersist = (
  config: StateCreator<MyStore>,
  options: PersistOptions<MyStore>
) => StateCreator<MyStore>

const useStore = create<MyStore>()(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
      }),
      {
        name: 'my-store',
      }
    )
  )
)

Examples

Type-Safe CRUD Store

interface Entity {
  id: string
  name: string
  createdAt: Date
}

interface CrudStore<T extends Entity> {
  items: T[]
  selectedId: string | null
  isLoading: boolean
  error: string | null

  // Computed
  selectedItem: T | null

  // Actions
  fetchAll: () => Promise<void>
  fetchOne: (id: string) => Promise<void>
  create: (data: Omit<T, 'id' | 'createdAt'>) => Promise<void>
  update: (id: string, data: Partial<T>) => Promise<void>
  delete: (id: string) => Promise<void>
  select: (id: string | null) => void
}

function createCrudStore<T extends Entity>(
  apiEndpoint: string
): StoreApi<CrudStore<T>> {
  return create<CrudStore<T>>()((set, get) => ({
    items: [],
    selectedId: null,
    isLoading: false,
    error: null,

    get selectedItem() {
      const { items, selectedId } = get()
      return items.find((item) => item.id === selectedId) ?? null
    },

    fetchAll: async () => {
      set({ isLoading: true, error: null })
      try {
        const response = await fetch(apiEndpoint)
        const items = await response.json()
        set({ items, isLoading: false })
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },

    fetchOne: async (id) => {
      set({ isLoading: true, error: null })
      try {
        const response = await fetch(`${apiEndpoint}/${id}`)
        const item = await response.json()
        set((state) => ({
          items: state.items.some((i) => i.id === id)
            ? state.items.map((i) => (i.id === id ? item : i))
            : [...state.items, item],
          isLoading: false,
        }))
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },

    create: async (data) => {
      set({ isLoading: true, error: null })
      try {
        const response = await fetch(apiEndpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data),
        })
        const newItem = await response.json()
        set((state) => ({
          items: [...state.items, newItem],
          isLoading: false,
        }))
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },

    update: async (id, data) => {
      set({ isLoading: true, error: null })
      try {
        const response = await fetch(`${apiEndpoint}/${id}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data),
        })
        const updatedItem = await response.json()
        set((state) => ({
          items: state.items.map((item) =>
            item.id === id ? updatedItem : item
          ),
          isLoading: false,
        }))
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },

    delete: async (id) => {
      set({ isLoading: true, error: null })
      try {
        await fetch(`${apiEndpoint}/${id}`, { method: 'DELETE' })
        set((state) => ({
          items: state.items.filter((item) => item.id !== id),
          selectedId: state.selectedId === id ? null : state.selectedId,
          isLoading: false,
        }))
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },

    select: (id) => set({ selectedId: id }),
  }))
}

// Usage
interface Product extends Entity {
  price: number
  description: string
}

const useProductStore = createCrudStore<Product>('/api/products')

Strongly Typed Actions

Use discriminated unions for type-safe action patterns:

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'set'; value: number }
  | { type: 'reset' }

interface CounterStore {
  count: number
  dispatch: (action: Action) => void
}

const useCounterStore = create<CounterStore>()((set) => ({
  count: 0,

  dispatch: (action) => {
    switch (action.type) {
      case 'increment':
        set((state) => ({ count: state.count + 1 }))
        break
      case 'decrement':
        set((state) => ({ count: state.count - 1 }))
        break
      case 'set':
        set({ count: action.value })
        break
      case 'reset':
        set({ count: 0 })
        break
    }
  },
}))

// Usage with full type safety
const dispatch = useCounterStore((state) => state.dispatch)
dispatch({ type: 'set', value: 10 }) // ✅ Type-safe
dispatch({ type: 'set', value: 'abc' }) // ❌ TypeScript error

Common Patterns

Namespace Pattern for Large Stores

Organize related state and actions:

interface Store {
  auth: {
    user: User | null
    token: string | null
    login: (credentials: Credentials) => Promise<void>
    logout: () => void
  }
  cart: {
    items: CartItem[]
    addItem: (item: Product) => void
    removeItem: (id: string) => void
    clear: () => void
  }
}

const useStore = create<Store>()((set) => ({
  auth: {
    user: null,
    token: null,
    login: async (credentials) => {
      const { user, token } = await api.login(credentials)
      set((state) => ({
        auth: { ...state.auth, user, token },
      }))
    },
    logout: () =>
      set((state) => ({
        auth: { ...state.auth, user: null, token: null },
      })),
  },
  cart: {
    items: [],
    addItem: (product) =>
      set((state) => ({
        cart: {
          ...state.cart,
          items: [...state.cart.items, { ...product, quantity: 1 }],
        },
      })),
    removeItem: (id) =>
      set((state) => ({
        cart: {
          ...state.cart,
          items: state.cart.items.filter((item) => item.id !== id),
        },
      })),
    clear: () =>
      set((state) => ({
        cart: { ...state.cart, items: [] },
      })),
  },
}))

// Usage
const login = useStore((state) => state.auth.login)
const cartItems = useStore((state) => state.cart.items)

Type-Safe Event Emitter

Create a typed event system:

type Events = {
  'user:login': { userId: string; timestamp: Date }
  'user:logout': { userId: string }
  'cart:add': { productId: string; quantity: number }
  'cart:remove': { productId: string }
}

type EventListener<T extends keyof Events> = (data: Events[T]) => void

interface EventStore {
  listeners: {
    [K in keyof Events]?: EventListener<K>[]
  }
  on: <T extends keyof Events>(event: T, listener: EventListener<T>) => void
  off: <T extends keyof Events>(event: T, listener: EventListener<T>) => void
  emit: <T extends keyof Events>(event: T, data: Events[T]) => void
}

const useEventStore = create<EventStore>()((set, get) => ({
  listeners: {},

  on: (event, listener) => {
    set((state) => ({
      listeners: {
        ...state.listeners,
        [event]: [...(state.listeners[event] || []), listener],
      },
    }))
  },

  off: (event, listener) => {
    set((state) => ({
      listeners: {
        ...state.listeners,
        [event]: (state.listeners[event] || []).filter((l) => l !== listener),
      },
    }))
  },

  emit: (event, data) => {
    const listeners = get().listeners[event] || []
    listeners.forEach((listener) => listener(data))
  },
}))

Anti-Patterns

❌ Don't Use any Types

// Bad
const useStore = create<any>()((set) => ({
  data: null,
  setData: (data: any) => set({ data }),
}))

// Good
interface Store {
  data: User | null
  setData: (data: User | null) => void
}

const useStore = create<Store>()((set) => ({
  data: null,
  setData: (data) => set({ data }),
}))

❌ Don't Ignore Return Types

// Bad: Ignoring return type
const useStore = create((set) => ({
  fetch: async () => {
    const data = await api.fetch()
    set({ data })
    // Missing return type
  },
}))

// Good: Explicit return types
interface Store {
  fetch: () => Promise<void>
}

const useStore = create<Store>()((set) => ({
  fetch: async (): Promise<void> => {
    const data = await api.fetch()
    set({ data })
  },
}))

❌ Don't Mix State and Actions in Types

// Bad: Confusing structure
interface Store {
  count: number
  increment: () => void
  name: string
  setName: (name: string) => void
}

// Good: Separate concerns
interface StoreState {
  count: number
  name: string
}

interface StoreActions {
  increment: () => void
  setName: (name: string) => void
}

type Store = StoreState & StoreActions

Related Skills

  • zustand-store-patterns: Basic store creation and usage
  • zustand-middleware: Using middleware with TypeScript
  • zustand-advanced-patterns: Advanced TypeScript patterns and techniques