Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide29 min read

Zustand

Zustand is a minimalist state management library for React. Simpler than Redux, no boilerplate, with full TypeScript support and middleware like persist and devtools.

Zustand - Minimalistyczny State Management dla React

Czym jest Zustand?

Zustand (z niemieckiego "stan") to lekka biblioteka do zarządzania stanem w aplikacjach React. Stworzona przez Poimandres (twórców Jotai, React Three Fiber), wyróżnia się:

  • Minimalnym API - tylko create i gotowe
  • Brakiem boilerplate - zero reducerów, actions, dispatch
  • Małym rozmiarem - ~1KB gzipped
  • TypeScript-first - pełna inferencja typów
  • Middleware - persist, devtools, immer, i więcej
  • Brak providerów - działa poza drzewem React

Dlaczego Zustand?

Problem z Redux

Code
TypeScript
// ❌ Redux - dużo boilerplate
// actions.ts
export const INCREMENT = 'INCREMENT'
export const increment = () => ({ type: INCREMENT })

// reducer.ts
const initialState = { count: 0 }
export function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + 1 }
    default:
      return state
  }
}

// store.ts
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({ reducer: { counter: counterReducer } })

// Provider w App.tsx
<Provider store={store}>...</Provider>

// Użycie w komponencie
const count = useSelector(state => state.counter.count)
const dispatch = useDispatch()
dispatch(increment())

Rozwiązanie z Zustand

Code
TypeScript
// ✅ Zustand - to wszystko!
import { create } from 'zustand'

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

// Użycie w komponencie
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)

Instalacja

Code
Bash
npm install zustand

# Opcjonalne middleware
npm install immer  # dla immutable updates

Podstawowe Użycie

Tworzenie Store

TSstores/counter-store.ts
TypeScript
// stores/counter-store.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 })),
}))

Użycie w Komponentach

TScomponents/Counter.tsx
TypeScript
// components/Counter.tsx
import { useCounterStore } from '@/stores/counter-store'

export function Counter() {
  // Subskrybuj tylko to czego potrzebujesz
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)

  return (
    <div className="flex items-center gap-4">
      <button onClick={decrement} className="px-4 py-2 bg-red-500 rounded">
        -
      </button>
      <span className="text-2xl font-bold">{count}</span>
      <button onClick={increment} className="px-4 py-2 bg-green-500 rounded">
        +
      </button>
    </div>
  )
}

// Alternatywnie - destructuring (ale uważaj na re-renders!)
export function CounterSimple() {
  const { count, increment, decrement } = useCounterStore()

  return (/* ... */)
}

Selektory i Optymalizacja

Code
TypeScript
// ❌ Źle - komponent re-renderuje się przy każdej zmianie store
const { count, name, email } = useCounterStore()

// ✅ Dobrze - komponent re-renderuje się tylko gdy count się zmieni
const count = useCounterStore((state) => state.count)

// ✅ Wiele wartości z shallow comparison
import { shallow } from 'zustand/shallow'

const { count, name } = useCounterStore(
  (state) => ({ count: state.count, name: state.name }),
  shallow
)

// ✅ Alternatywnie - useShallow hook
import { useShallow } from 'zustand/react/shallow'

const { count, name } = useCounterStore(
  useShallow((state) => ({ count: state.count, name: state.name }))
)

Store z Async Actions

TSstores/posts-store.ts
TypeScript
// stores/posts-store.ts
import { create } from 'zustand'

interface Post {
  id: string
  title: string
  content: string
}

interface PostsState {
  posts: Post[]
  loading: boolean
  error: string | null

  fetchPosts: () => Promise<void>
  addPost: (post: Omit<Post, 'id'>) => Promise<void>
  deletePost: (id: string) => Promise<void>
  updatePost: (id: string, data: Partial<Post>) => Promise<void>
}

export const usePostsStore = create<PostsState>((set, get) => ({
  posts: [],
  loading: false,
  error: null,

  fetchPosts: async () => {
    set({ loading: true, error: null })

    try {
      const response = await fetch('/api/posts')
      if (!response.ok) throw new Error('Failed to fetch')

      const posts = await response.json()
      set({ posts, loading: false })
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Unknown error',
        loading: false,
      })
    }
  },

  addPost: async (newPost) => {
    set({ loading: true })

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      })

      const post = await response.json()

      // Dodaj do listy
      set((state) => ({
        posts: [...state.posts, post],
        loading: false,
      }))
    } catch (error) {
      set({ error: 'Failed to add post', loading: false })
    }
  },

  deletePost: async (id) => {
    // Optimistic update
    const previousPosts = get().posts
    set((state) => ({
      posts: state.posts.filter((p) => p.id !== id),
    }))

    try {
      await fetch(`/api/posts/${id}`, { method: 'DELETE' })
    } catch (error) {
      // Rollback on error
      set({ posts: previousPosts, error: 'Failed to delete' })
    }
  },

  updatePost: async (id, data) => {
    try {
      const response = await fetch(`/api/posts/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })

      const updatedPost = await response.json()

      set((state) => ({
        posts: state.posts.map((p) => (p.id === id ? updatedPost : p)),
      }))
    } catch (error) {
      set({ error: 'Failed to update' })
    }
  },
}))

Użycie w komponencie

Code
TypeScript
'use client'

import { useEffect } from 'react'
import { usePostsStore } from '@/stores/posts-store'

export function PostsList() {
  const posts = usePostsStore((state) => state.posts)
  const loading = usePostsStore((state) => state.loading)
  const error = usePostsStore((state) => state.error)
  const fetchPosts = usePostsStore((state) => state.fetchPosts)
  const deletePost = usePostsStore((state) => state.deletePost)

  useEffect(() => {
    fetchPosts()
  }, [fetchPosts])

  if (loading) return <Skeleton />
  if (error) return <Error message={error} />

  return (
    <ul className="space-y-4">
      {posts.map((post) => (
        <li key={post.id} className="p-4 bg-white rounded shadow">
          <h3 className="font-bold">{post.title}</h3>
          <p className="text-gray-600">{post.content}</p>
          <button
            onClick={() => deletePost(post.id)}
            className="mt-2 text-red-500"
          >
            Usuń
          </button>
        </li>
      ))}
    </ul>
  )
}

Middleware

Persist - zapisywanie do localStorage

Code
TypeScript
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: (lang: string) => void
  toggleNotifications: () => void
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'pl',
      notifications: true,

      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () =>
        set((state) => ({ notifications: !state.notifications })),
    }),
    {
      name: 'settings-storage', // klucz w localStorage
      storage: createJSONStorage(() => localStorage),
      // Wybierz które pola persistować
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
        notifications: state.notifications,
      }),
    }
  )
)

Persist z SSR (Next.js)

Code
TypeScript
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({/* ... */}),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      // Poczekaj na hydration
      skipHydration: true,
    }
  )
)

// W komponencie lub layout - hydrate manualnie
'use client'

import { useEffect } from 'react'
import { useSettingsStore } from '@/stores/settings-store'

export function HydrationProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    useSettingsStore.persist.rehydrate()
  }, [])

  return <>{children}</>
}

// Lub użyj hooka
export function useHydration() {
  const [hydrated, setHydrated] = useState(false)

  useEffect(() => {
    const unsubHydrate = useSettingsStore.persist.onHydrate(() => {
      setHydrated(false)
    })

    const unsubFinish = useSettingsStore.persist.onFinishHydration(() => {
      setHydrated(true)
    })

    useSettingsStore.persist.rehydrate()

    return () => {
      unsubHydrate()
      unsubFinish()
    }
  }, [])

  return hydrated
}

DevTools

Code
TypeScript
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false, // replace
          'increment' // action name w DevTools
        ),
    }),
    {
      name: 'Counter Store', // nazwa w DevTools
      enabled: process.env.NODE_ENV === 'development',
    }
  )
)

Immer - immutable updates

Code
TypeScript
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface TodosState {
  todos: { id: string; text: string; completed: boolean }[]
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  updateTodo: (id: string, text: string) => void
}

export const useTodosStore = create<TodosState>()(
  immer((set) => ({
    todos: [],

    addTodo: (text) =>
      set((state) => {
        // Możesz mutować bezpośrednio dzięki Immer!
        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
        }
      }),
  }))
)

Łączenie Middleware

Code
TypeScript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

// Kolejność ma znaczenie! Od zewnątrz do wewnątrz:
// devtools(persist(immer(...)))

export const useAppStore = create<AppState>()(
  devtools(
    persist(
      immer((set, get) => ({
        // ... state i actions
      })),
      { name: 'app-storage' }
    ),
    { name: 'App Store' }
  )
)

Slices Pattern (Duże Store)

Podział na slices

TSstores/slices/auth-slice.ts
TypeScript
// stores/slices/auth-slice.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, // Pełny typ store
  [],
  [],
  AuthSlice
> = (set) => ({
  user: null,
  isAuthenticated: false,

  login: async (email, password) => {
    const user = await authService.login(email, password)
    set({ user, isAuthenticated: true })
  },

  logout: () => set({ user: null, isAuthenticated: false }),
})

// stores/slices/cart-slice.ts
export interface CartSlice {
  items: CartItem[]
  total: number
  addItem: (product: Product) => void
  removeItem: (id: string) => void
  clearCart: () => void
}

export const createCartSlice: StateCreator<
  AuthSlice & CartSlice,
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
  total: 0,

  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === product.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
          total: state.total + product.price,
        }
      }
      return {
        items: [...state.items, { ...product, quantity: 1 }],
        total: state.total + product.price,
      }
    }),

  removeItem: (id) =>
    set((state) => {
      const item = state.items.find((i) => i.id === id)
      return {
        items: state.items.filter((i) => i.id !== id),
        total: state.total - (item ? item.price * item.quantity : 0),
      }
    }),

  clearCart: () => set({ items: [], total: 0 }),
})

// stores/app-store.ts
import { create } from 'zustand'
import { createAuthSlice, AuthSlice } from './slices/auth-slice'
import { createCartSlice, CartSlice } from './slices/cart-slice'

export const useAppStore = create<AuthSlice & CartSlice>()((...args) => ({
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
}))

// Użycie
const user = useAppStore((state) => state.user)
const addItem = useAppStore((state) => state.addItem)

Computed Values (Derived State)

Podstawowe computed

Code
TypeScript
import { create } from 'zustand'

interface CartState {
  items: CartItem[]
  // Computed values jako gettery
  get itemCount(): number
  get total(): number
  get isEmpty(): boolean
}

// Zustand nie ma wbudowanych computed, ale można użyć selektorów
export const useCartStore = create<CartState>((set, get) => ({
  items: [],

  // Nie przechowuj computed w store!
}))

// Zamiast tego - custom hooks z selektorami
export function useCartTotal() {
  return useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
}

export function useCartItemCount() {
  return useCartStore((state) =>
    state.items.reduce((count, item) => count + item.quantity, 0)
  )
}

export function useCartIsEmpty() {
  return useCartStore((state) => state.items.length === 0)
}

Z subscribeWithSelector

Code
TypeScript
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

export const useCartStore = create<CartState>()(
  subscribeWithSelector((set, get) => ({
    items: [],
    total: 0,

    addItem: (product) => {
      set((state) => ({
        items: [...state.items, product],
      }))
      // Przelicz total po dodaniu
      const newTotal = get().items.reduce(
        (sum, i) => sum + i.price * i.quantity,
        0
      )
      set({ total: newTotal })
    },
  }))
)

// Subscribe na konkretne zmiany
useCartStore.subscribe(
  (state) => state.items,
  (items, previousItems) => {
    console.log('Items changed:', items.length - previousItems.length)
  }
)

Dostęp do Store poza React

Code
TypeScript
// Store jest dostępny bezpośrednio
const state = useCounterStore.getState()
console.log(state.count)

// Wywołaj action
useCounterStore.getState().increment()

// Set state bezpośrednio
useCounterStore.setState({ count: 100 })

// Subscribe na zmiany
const unsubscribe = useCounterStore.subscribe((state, prevState) => {
  console.log('State changed:', state.count, prevState.count)
})

// Użyteczne w:
// - Server Actions
// - API routes
// - Event handlers poza React
// - Testach

Przykład z Server Actions

TSactions.ts
TypeScript
// actions.ts
'use server'

import { useCartStore } from '@/stores/cart-store'

export async function processOrder() {
  const cart = useCartStore.getState()

  if (cart.items.length === 0) {
    throw new Error('Cart is empty')
  }

  // Przetwórz zamówienie
  const order = await createOrder(cart.items)

  // Wyczyść koszyk
  useCartStore.getState().clearCart()

  return order
}

TypeScript Patterns

Strict types z generics

Code
TypeScript
// Typ dla slice creator
type SliceCreator<T> = StateCreator<AppState, [], [], T>

// Typ dla actions
type Actions<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K]
}

// Typ dla state (bez actions)
type State<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]
}

// Użycie
type CartActions = Actions<CartSlice> // tylko metody
type CartData = State<CartSlice> // tylko dane

Typowany selector hook

Code
TypeScript
export function useCartSelector<T>(selector: (state: CartState) => T): T {
  return useCartStore(selector)
}

// Użycie z autocomplete
const items = useCartSelector((s) => s.items)
const total = useCartSelector((s) => s.total)

Testing

Testowanie store

TS__tests__/counter-store.test.ts
TypeScript
// __tests__/counter-store.test.ts
import { useCounterStore } from '@/stores/counter-store'

describe('Counter Store', () => {
  beforeEach(() => {
    // Reset store przed każdym testem
    useCounterStore.setState({ count: 0 })
  })

  it('should increment count', () => {
    useCounterStore.getState().increment()
    expect(useCounterStore.getState().count).toBe(1)
  })

  it('should decrement count', () => {
    useCounterStore.setState({ count: 5 })
    useCounterStore.getState().decrement()
    expect(useCounterStore.getState().count).toBe(4)
  })

  it('should reset count', () => {
    useCounterStore.setState({ count: 100 })
    useCounterStore.getState().reset()
    expect(useCounterStore.getState().count).toBe(0)
  })
})

Mockowanie w testach komponentów

TS__tests__/Counter.test.tsx
TypeScript
// __tests__/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
import { useCounterStore } from '@/stores/counter-store'

// Mock store
jest.mock('@/stores/counter-store')

describe('Counter Component', () => {
  const mockIncrement = jest.fn()

  beforeEach(() => {
    jest.mocked(useCounterStore).mockImplementation((selector) =>
      selector({
        count: 5,
        increment: mockIncrement,
        decrement: jest.fn(),
        reset: jest.fn(),
      })
    )
  })

  it('should display count', () => {
    render(<Counter />)
    expect(screen.getByText('5')).toBeInTheDocument()
  })

  it('should call increment on button click', () => {
    render(<Counter />)
    fireEvent.click(screen.getByText('+'))
    expect(mockIncrement).toHaveBeenCalled()
  })
})

Best Practices

1. Jeden store vs wiele stores

Code
TypeScript
// ✅ Dla małych aplikacji - jeden store
const useStore = create((set) => ({
  user: null,
  cart: [],
  theme: 'light',
}))

// ✅ Dla dużych aplikacji - osobne stores
const useAuthStore = create(/* ... */)
const useCartStore = create(/* ... */)
const useUIStore = create(/* ... */)

2. Selektory dla optymalizacji

Code
TypeScript
// ❌ Re-render przy każdej zmianie
const store = useStore()

// ✅ Re-render tylko gdy count się zmieni
const count = useStore((s) => s.count)

3. Actions w store, nie w komponentach

Code
TypeScript
// ❌ Logika w komponencie
function Component() {
  const setUser = useStore((s) => s.setUser)

  async function login(data) {
    const user = await api.login(data)
    setUser(user)
    localStorage.setItem('token', user.token)
  }
}

// ✅ Logika w store
const useAuthStore = create((set) => ({
  user: null,
  login: async (data) => {
    const user = await api.login(data)
    localStorage.setItem('token', user.token)
    set({ user })
  },
}))

4. Unikaj circular dependencies

Code
TypeScript
// ❌ Store A importuje Store B, Store B importuje Store A

// ✅ Użyj getState() lub subscriptions
const useStoreA = create((set) => ({
  doSomething: () => {
    const storeB = useStoreB.getState()
    // ...
  },
}))

Zustand vs Alternatywy

CechaZustandRedux ToolkitJotaiRecoil
Bundle size~1KB~10KB~3KB~20KB
BoilerplateMinimalnyŚredniMinimalnyŚredni
Learning curveŁatwyŚredniŁatwyŚredni
DevToolsTakTakTakTak
PersistMiddlewareMiddlewarePluginPlugin
AtomsNieNieTakTak
Server stateNieRTK QueryNieNie

Kiedy użyć Zustand?

Użyj Zustand gdy:

  • Potrzebujesz prostego global state
  • Chcesz uniknąć boilerplate Redux
  • Masz średniej wielkości aplikację
  • Zależy Ci na małym bundle size

Rozważ alternatywy gdy:

  • Potrzebujesz atomic state (Jotai)
  • Masz duży zespół przyzwyczajony do Redux
  • Potrzebujesz wbudowanego server state (RTK Query)

FAQ

Jak używać z Next.js App Router?

Używaj Zustand normalnie w Client Components. Dla SSR użyj skipHydration w persist.

Czy potrzebuję Provider?

Nie! Zustand działa bez providerów. Store jest globalny.

Jak debugować?

Użyj middleware devtools i Redux DevTools extension.

Czy mogę użyć z React Native?

Tak! Zustand jest framework-agnostic. Dla persist użyj AsyncStorage.

Jak migrować z Redux?

Stopniowo - Zustand może współistnieć z Redux. Przenoś feature po feature.

Podsumowanie

Zustand to najlepsza biblioteka state management dla większości aplikacji React:

  • Prostota - minimalne API, szybka nauka
  • Wydajność - granularne re-renders dzięki selektorom
  • TypeScript - pełna inferencja typów
  • Elastyczność - middleware dla każdego use case
  • Rozmiar - ~1KB, bez dependencji

Jeśli frustrowała Cię złożoność Redux, Zustand to odpowiedź.


Zustand - Minimalist State Management for React

What is Zustand?

Zustand (German for "state") is a lightweight state management library for React applications. Created by Poimandres (makers of Jotai, React Three Fiber), it stands out with:

  • Minimal API - just create and you are good to go
  • No boilerplate - zero reducers, actions, dispatch
  • Small size - ~1KB gzipped
  • TypeScript-first - full type inference
  • Middleware - persist, devtools, immer, and more
  • No providers - works outside the React tree

Why Zustand?

The problem with Redux

Code
TypeScript
// ❌ Redux - lots of boilerplate
// actions.ts
export const INCREMENT = 'INCREMENT'
export const increment = () => ({ type: INCREMENT })

// reducer.ts
const initialState = { count: 0 }
export function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + 1 }
    default:
      return state
  }
}

// store.ts
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({ reducer: { counter: counterReducer } })

// Provider in App.tsx
<Provider store={store}>...</Provider>

// Usage in component
const count = useSelector(state => state.counter.count)
const dispatch = useDispatch()
dispatch(increment())

The solution with Zustand

Code
TypeScript
// ✅ Zustand - that's all!
import { create } from 'zustand'

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

// Usage in component
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)

Installation

Code
Bash
npm install zustand

# Optional middleware
npm install immer  # for immutable updates

Basic usage

Creating a store

TSstores/counter-store.ts
TypeScript
// stores/counter-store.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 components

TScomponents/Counter.tsx
TypeScript
// components/Counter.tsx
import { useCounterStore } from '@/stores/counter-store'

export function Counter() {
  // Subscribe only to what you need
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)

  return (
    <div className="flex items-center gap-4">
      <button onClick={decrement} className="px-4 py-2 bg-red-500 rounded">
        -
      </button>
      <span className="text-2xl font-bold">{count}</span>
      <button onClick={increment} className="px-4 py-2 bg-green-500 rounded">
        +
      </button>
    </div>
  )
}

// Alternatively - destructuring (but beware of re-renders!)
export function CounterSimple() {
  const { count, increment, decrement } = useCounterStore()

  return (/* ... */)
}

Selectors and optimization

Code
TypeScript
// ❌ Bad - component re-renders on every store change
const { count, name, email } = useCounterStore()

// ✅ Good - component re-renders only when count changes
const count = useCounterStore((state) => state.count)

// ✅ Multiple values with shallow comparison
import { shallow } from 'zustand/shallow'

const { count, name } = useCounterStore(
  (state) => ({ count: state.count, name: state.name }),
  shallow
)

// ✅ Alternatively - useShallow hook
import { useShallow } from 'zustand/react/shallow'

const { count, name } = useCounterStore(
  useShallow((state) => ({ count: state.count, name: state.name }))
)

Store with async actions

TSstores/posts-store.ts
TypeScript
// stores/posts-store.ts
import { create } from 'zustand'

interface Post {
  id: string
  title: string
  content: string
}

interface PostsState {
  posts: Post[]
  loading: boolean
  error: string | null

  fetchPosts: () => Promise<void>
  addPost: (post: Omit<Post, 'id'>) => Promise<void>
  deletePost: (id: string) => Promise<void>
  updatePost: (id: string, data: Partial<Post>) => Promise<void>
}

export const usePostsStore = create<PostsState>((set, get) => ({
  posts: [],
  loading: false,
  error: null,

  fetchPosts: async () => {
    set({ loading: true, error: null })

    try {
      const response = await fetch('/api/posts')
      if (!response.ok) throw new Error('Failed to fetch')

      const posts = await response.json()
      set({ posts, loading: false })
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : 'Unknown error',
        loading: false,
      })
    }
  },

  addPost: async (newPost) => {
    set({ loading: true })

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      })

      const post = await response.json()

      // Add to the list
      set((state) => ({
        posts: [...state.posts, post],
        loading: false,
      }))
    } catch (error) {
      set({ error: 'Failed to add post', loading: false })
    }
  },

  deletePost: async (id) => {
    // Optimistic update
    const previousPosts = get().posts
    set((state) => ({
      posts: state.posts.filter((p) => p.id !== id),
    }))

    try {
      await fetch(`/api/posts/${id}`, { method: 'DELETE' })
    } catch (error) {
      // Rollback on error
      set({ posts: previousPosts, error: 'Failed to delete' })
    }
  },

  updatePost: async (id, data) => {
    try {
      const response = await fetch(`/api/posts/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })

      const updatedPost = await response.json()

      set((state) => ({
        posts: state.posts.map((p) => (p.id === id ? updatedPost : p)),
      }))
    } catch (error) {
      set({ error: 'Failed to update' })
    }
  },
}))

Usage in a component

Code
TypeScript
'use client'

import { useEffect } from 'react'
import { usePostsStore } from '@/stores/posts-store'

export function PostsList() {
  const posts = usePostsStore((state) => state.posts)
  const loading = usePostsStore((state) => state.loading)
  const error = usePostsStore((state) => state.error)
  const fetchPosts = usePostsStore((state) => state.fetchPosts)
  const deletePost = usePostsStore((state) => state.deletePost)

  useEffect(() => {
    fetchPosts()
  }, [fetchPosts])

  if (loading) return <Skeleton />
  if (error) return <Error message={error} />

  return (
    <ul className="space-y-4">
      {posts.map((post) => (
        <li key={post.id} className="p-4 bg-white rounded shadow">
          <h3 className="font-bold">{post.title}</h3>
          <p className="text-gray-600">{post.content}</p>
          <button
            onClick={() => deletePost(post.id)}
            className="mt-2 text-red-500"
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  )
}

Middleware

Persist - saving to localStorage

Code
TypeScript
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: (lang: string) => void
  toggleNotifications: () => void
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'pl',
      notifications: true,

      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () =>
        set((state) => ({ notifications: !state.notifications })),
    }),
    {
      name: 'settings-storage', // localStorage key
      storage: createJSONStorage(() => localStorage),
      // Choose which fields to persist
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
        notifications: state.notifications,
      }),
    }
  )
)

Persist with SSR (Next.js)

Code
TypeScript
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({/* ... */}),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      // Wait for hydration
      skipHydration: true,
    }
  )
)

// In a component or layout - hydrate manually
'use client'

import { useEffect } from 'react'
import { useSettingsStore } from '@/stores/settings-store'

export function HydrationProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    useSettingsStore.persist.rehydrate()
  }, [])

  return <>{children}</>
}

// Or use a hook
export function useHydration() {
  const [hydrated, setHydrated] = useState(false)

  useEffect(() => {
    const unsubHydrate = useSettingsStore.persist.onHydrate(() => {
      setHydrated(false)
    })

    const unsubFinish = useSettingsStore.persist.onFinishHydration(() => {
      setHydrated(true)
    })

    useSettingsStore.persist.rehydrate()

    return () => {
      unsubHydrate()
      unsubFinish()
    }
  }, [])

  return hydrated
}

DevTools

Code
TypeScript
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false, // replace
          'increment' // action name in DevTools
        ),
    }),
    {
      name: 'Counter Store', // name in DevTools
      enabled: process.env.NODE_ENV === 'development',
    }
  )
)

Immer - immutable updates

Code
TypeScript
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface TodosState {
  todos: { id: string; text: string; completed: boolean }[]
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  updateTodo: (id: string, text: string) => void
}

export const useTodosStore = create<TodosState>()(
  immer((set) => ({
    todos: [],

    addTodo: (text) =>
      set((state) => {
        // You can mutate directly thanks to Immer!
        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
        }
      }),
  }))
)

Combining middleware

Code
TypeScript
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

// Order matters! From outside to inside:
// devtools(persist(immer(...)))

export const useAppStore = create<AppState>()(
  devtools(
    persist(
      immer((set, get) => ({
        // ... state and actions
      })),
      { name: 'app-storage' }
    ),
    { name: 'App Store' }
  )
)

Slices pattern (large stores)

Splitting into slices

TSstores/slices/auth-slice.ts
TypeScript
// stores/slices/auth-slice.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, // Full store type
  [],
  [],
  AuthSlice
> = (set) => ({
  user: null,
  isAuthenticated: false,

  login: async (email, password) => {
    const user = await authService.login(email, password)
    set({ user, isAuthenticated: true })
  },

  logout: () => set({ user: null, isAuthenticated: false }),
})

// stores/slices/cart-slice.ts
export interface CartSlice {
  items: CartItem[]
  total: number
  addItem: (product: Product) => void
  removeItem: (id: string) => void
  clearCart: () => void
}

export const createCartSlice: StateCreator<
  AuthSlice & CartSlice,
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
  total: 0,

  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === product.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
          total: state.total + product.price,
        }
      }
      return {
        items: [...state.items, { ...product, quantity: 1 }],
        total: state.total + product.price,
      }
    }),

  removeItem: (id) =>
    set((state) => {
      const item = state.items.find((i) => i.id === id)
      return {
        items: state.items.filter((i) => i.id !== id),
        total: state.total - (item ? item.price * item.quantity : 0),
      }
    }),

  clearCart: () => set({ items: [], total: 0 }),
})

// stores/app-store.ts
import { create } from 'zustand'
import { createAuthSlice, AuthSlice } from './slices/auth-slice'
import { createCartSlice, CartSlice } from './slices/cart-slice'

export const useAppStore = create<AuthSlice & CartSlice>()((...args) => ({
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
}))

// Usage
const user = useAppStore((state) => state.user)
const addItem = useAppStore((state) => state.addItem)

Computed values (derived state)

Basic computed

Code
TypeScript
import { create } from 'zustand'

interface CartState {
  items: CartItem[]
  // Computed values as getters
  get itemCount(): number
  get total(): number
  get isEmpty(): boolean
}

// Zustand does not have built-in computed, but you can use selectors
export const useCartStore = create<CartState>((set, get) => ({
  items: [],

  // Do not store computed values in the store!
}))

// Instead - custom hooks with selectors
export function useCartTotal() {
  return useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
}

export function useCartItemCount() {
  return useCartStore((state) =>
    state.items.reduce((count, item) => count + item.quantity, 0)
  )
}

export function useCartIsEmpty() {
  return useCartStore((state) => state.items.length === 0)
}

With subscribeWithSelector

Code
TypeScript
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

export const useCartStore = create<CartState>()(
  subscribeWithSelector((set, get) => ({
    items: [],
    total: 0,

    addItem: (product) => {
      set((state) => ({
        items: [...state.items, product],
      }))
      // Recalculate total after adding
      const newTotal = get().items.reduce(
        (sum, i) => sum + i.price * i.quantity,
        0
      )
      set({ total: newTotal })
    },
  }))
)

// Subscribe to specific changes
useCartStore.subscribe(
  (state) => state.items,
  (items, previousItems) => {
    console.log('Items changed:', items.length - previousItems.length)
  }
)

Accessing the store outside React

Code
TypeScript
// The store is accessible directly
const state = useCounterStore.getState()
console.log(state.count)

// Call an action
useCounterStore.getState().increment()

// Set state directly
useCounterStore.setState({ count: 100 })

// Subscribe to changes
const unsubscribe = useCounterStore.subscribe((state, prevState) => {
  console.log('State changed:', state.count, prevState.count)
})

// Useful in:
// - Server Actions
// - API routes
// - Event handlers outside React
// - Tests

Example with Server Actions

TSactions.ts
TypeScript
// actions.ts
'use server'

import { useCartStore } from '@/stores/cart-store'

export async function processOrder() {
  const cart = useCartStore.getState()

  if (cart.items.length === 0) {
    throw new Error('Cart is empty')
  }

  // Process the order
  const order = await createOrder(cart.items)

  // Clear the cart
  useCartStore.getState().clearCart()

  return order
}

TypeScript patterns

Strict types with generics

Code
TypeScript
// Type for slice creator
type SliceCreator<T> = StateCreator<AppState, [], [], T>

// Type for actions
type Actions<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K]
}

// Type for state (without actions)
type State<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]
}

// Usage
type CartActions = Actions<CartSlice> // methods only
type CartData = State<CartSlice> // data only

Typed selector hook

Code
TypeScript
export function useCartSelector<T>(selector: (state: CartState) => T): T {
  return useCartStore(selector)
}

// Usage with autocomplete
const items = useCartSelector((s) => s.items)
const total = useCartSelector((s) => s.total)

Testing

Testing the store

TS__tests__/counter-store.test.ts
TypeScript
// __tests__/counter-store.test.ts
import { useCounterStore } from '@/stores/counter-store'

describe('Counter Store', () => {
  beforeEach(() => {
    // Reset store before each test
    useCounterStore.setState({ count: 0 })
  })

  it('should increment count', () => {
    useCounterStore.getState().increment()
    expect(useCounterStore.getState().count).toBe(1)
  })

  it('should decrement count', () => {
    useCounterStore.setState({ count: 5 })
    useCounterStore.getState().decrement()
    expect(useCounterStore.getState().count).toBe(4)
  })

  it('should reset count', () => {
    useCounterStore.setState({ count: 100 })
    useCounterStore.getState().reset()
    expect(useCounterStore.getState().count).toBe(0)
  })
})

Mocking in component tests

TS__tests__/Counter.test.tsx
TypeScript
// __tests__/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
import { useCounterStore } from '@/stores/counter-store'

// Mock store
jest.mock('@/stores/counter-store')

describe('Counter Component', () => {
  const mockIncrement = jest.fn()

  beforeEach(() => {
    jest.mocked(useCounterStore).mockImplementation((selector) =>
      selector({
        count: 5,
        increment: mockIncrement,
        decrement: jest.fn(),
        reset: jest.fn(),
      })
    )
  })

  it('should display count', () => {
    render(<Counter />)
    expect(screen.getByText('5')).toBeInTheDocument()
  })

  it('should call increment on button click', () => {
    render(<Counter />)
    fireEvent.click(screen.getByText('+'))
    expect(mockIncrement).toHaveBeenCalled()
  })
})

Best practices

1. One store vs multiple stores

Code
TypeScript
// ✅ For small applications - one store
const useStore = create((set) => ({
  user: null,
  cart: [],
  theme: 'light',
}))

// ✅ For large applications - separate stores
const useAuthStore = create(/* ... */)
const useCartStore = create(/* ... */)
const useUIStore = create(/* ... */)

2. Selectors for optimization

Code
TypeScript
// ❌ Re-render on every change
const store = useStore()

// ✅ Re-render only when count changes
const count = useStore((s) => s.count)

3. Actions in the store, not in components

Code
TypeScript
// ❌ Logic in the component
function Component() {
  const setUser = useStore((s) => s.setUser)

  async function login(data) {
    const user = await api.login(data)
    setUser(user)
    localStorage.setItem('token', user.token)
  }
}

// ✅ Logic in the store
const useAuthStore = create((set) => ({
  user: null,
  login: async (data) => {
    const user = await api.login(data)
    localStorage.setItem('token', user.token)
    set({ user })
  },
}))

4. Avoid circular dependencies

Code
TypeScript
// ❌ Store A imports Store B, Store B imports Store A

// ✅ Use getState() or subscriptions
const useStoreA = create((set) => ({
  doSomething: () => {
    const storeB = useStoreB.getState()
    // ...
  },
}))

Zustand vs alternatives

FeatureZustandRedux ToolkitJotaiRecoil
Bundle size~1KB~10KB~3KB~20KB
BoilerplateMinimalMediumMinimalMedium
Learning curveEasyMediumEasyMedium
DevToolsYesYesYesYes
PersistMiddlewareMiddlewarePluginPlugin
AtomsNoNoYesYes
Server stateNoRTK QueryNoNo

When to use Zustand?

Use Zustand when:

  • You need simple global state
  • You want to avoid Redux boilerplate
  • You have a medium-sized application
  • Bundle size matters to you

Consider alternatives when:

  • You need atomic state (Jotai)
  • You have a large team accustomed to Redux
  • You need built-in server state (RTK Query)

FAQ

How to use with Next.js App Router?

Use Zustand normally in Client Components. For SSR use skipHydration in persist.

Do I need a Provider?

No! Zustand works without providers. The store is global.

How to debug?

Use the devtools middleware and the Redux DevTools extension.

Can I use it with React Native?

Yes! Zustand is framework-agnostic. For persist use AsyncStorage.

How to migrate from Redux?

Gradually - Zustand can coexist with Redux. Migrate feature by feature.

Summary

Zustand is the best state management library for most React applications:

  • Simplicity - minimal API, fast learning curve
  • Performance - granular re-renders thanks to selectors
  • TypeScript - full type inference
  • Flexibility - middleware for every use case
  • Size - ~1KB, no dependencies

If you have been frustrated by Redux complexity, Zustand is the answer.