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
createi 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
// ❌ 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
// ✅ 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
npm install zustand
# Opcjonalne middleware
npm install immer # dla immutable updatesPodstawowe Użycie
Tworzenie Store
// 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
// 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
// ❌ Ź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
// 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
'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
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)
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
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
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
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
// 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
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
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
// 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
// - TestachPrzykład z Server Actions
// 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
// 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 daneTypowany selector hook
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
// __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
// __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
// ✅ 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
// ❌ 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
// ❌ 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
// ❌ 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
| Cecha | Zustand | Redux Toolkit | Jotai | Recoil |
|---|---|---|---|---|
| Bundle size | ~1KB | ~10KB | ~3KB | ~20KB |
| Boilerplate | Minimalny | Średni | Minimalny | Średni |
| Learning curve | Łatwy | Średni | Łatwy | Średni |
| DevTools | Tak | Tak | Tak | Tak |
| Persist | Middleware | Middleware | Plugin | Plugin |
| Atoms | Nie | Nie | Tak | Tak |
| Server state | Nie | RTK Query | Nie | Nie |
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
createand 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
// ❌ 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
// ✅ 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
npm install zustand
# Optional middleware
npm install immer # for immutable updatesBasic usage
Creating a store
// 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
// 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
// ❌ 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
// 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
'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
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)
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
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
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
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
// 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
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
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
// 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
// - TestsExample with Server Actions
// 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
// 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 onlyTyped selector hook
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
// __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
// __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
// ✅ 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
// ❌ 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
// ❌ 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
// ❌ 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
| Feature | Zustand | Redux Toolkit | Jotai | Recoil |
|---|---|---|---|---|
| Bundle size | ~1KB | ~10KB | ~3KB | ~20KB |
| Boilerplate | Minimal | Medium | Minimal | Medium |
| Learning curve | Easy | Medium | Easy | Medium |
| DevTools | Yes | Yes | Yes | Yes |
| Persist | Middleware | Middleware | Plugin | Plugin |
| Atoms | No | No | Yes | Yes |
| Server state | No | RTK Query | No | No |
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.