React Zustand Guide: Lightweight Global State

Zustand is a minimal, fast global state library for React. At ~1KB gzipped, it avoids Redux boilerplate while providing a clean API that works outside components, supports middleware, and integrates with React DevTools. You define a store as a single function — state and actions together — and access exactly the slice you need in any component via selector hooks, avoiding unnecessary re-renders.

Creating a Store

A Zustand store is created with create. You pass a function that receives set and get and returns an object containing both state and actions. The returned hook is used directly in components — no Provider wrapper needed.

import { create } from 'zustand'

// Counter store — state and actions in one object
const useCounterStore = create((set) => ({
  count: 0,
  step: 1,
  increment: () => set((state) => ({ count: state.count + state.step })),
  decrement: () => set((state) => ({ count: state.count - state.step })),
  setStep: (step) => set({ step }),
  reset: () => set({ count: 0, step: 1 }),
}))

// Usage — subscribe to only the slice you need
function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

// Cart store — more complex state
const useCartStore = create((set, get) => ({
  items: [],
  isOpen: false,

  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, qty: i.qty + 1 } : i
        ),
      }
    }
    return { items: [...state.items, { ...product, qty: 1 }] }
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),

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

  toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),

  // Derived value via get()
  getTotal: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),

  getItemCount: () => get().items.reduce((sum, i) => sum + i.qty, 0),
}))
Note: No Provider is required. The store is a module-level singleton. Multiple components can read from the same store simultaneously without any wrapper components or prop drilling.

Selectors and Performance

Zustand re-renders a component only when the value returned by its selector changes. Always select the minimum state slice needed. Selecting the entire state object causes re-renders on every state change — the same performance anti-pattern as connecting to the whole Redux store.

// Good — select only what you need
const count = useCounterStore((state) => state.count)
const items = useCartStore((state) => state.items)
const addItem = useCartStore((state) => state.addItem)  // stable function ref

// Bad — subscribes to the entire store
const store = useCartStore()  // re-renders on ANY state change

// Computed / derived selectors
const itemCount = useCartStore((state) =>
  state.items.reduce((sum, i) => sum + i.qty, 0)
)

// Equality function for object selectors
import { useShallow } from 'zustand/react/shallow'

// Without useShallow — new object on every render causes infinite re-renders
const { count, step } = useCounterStore((state) => ({ count: state.count, step: state.step }))  // BAD

// With useShallow — only re-renders when count or step actually changes
const { count, step } = useCounterStore(
  useShallow((state) => ({ count: state.count, step: state.step }))
)

// Extracting multiple actions — stable references, no shallow needed
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)

// Memoized selector with parameters
const selectItemById = (id) => (state) => state.items.find(i => i.id === id)

function CartItem({ id }) {
  const item = useCartStore(selectItemById(id))
  return <div>{item?.name} × {item?.qty}</div>
}

Async Actions

Zustand actions are plain functions — they can be async without any middleware. Call set inside the async action to update state at any point during the async operation. This is far simpler than Redux Thunk or Redux Saga for the same pattern.

const useUserStore = create((set, get) => ({
  users: [],
  selectedUser: null,
  loading: false,
  error: null,

  fetchUsers: async () => {
    set({ loading: true, error: null })
    try {
      const res = await fetch('/api/users')
      if (!res.ok) throw new Error('Failed to fetch users')
      const users = await res.json()
      set({ users, loading: false })
    } catch (err) {
      set({ error: err.message, loading: false })
    }
  },

  fetchUser: async (id) => {
    set({ loading: true })
    try {
      const res = await fetch(`/api/users/${id}`)
      const user = await res.json()
      set({ selectedUser: user, loading: false })
    } catch (err) {
      set({ error: err.message, loading: false })
    }
  },

  updateUser: async (id, data) => {
    // Optimistic update
    const previous = get().users
    set((state) => ({
      users: state.users.map(u => u.id === id ? { ...u, ...data } : u),
    }))
    try {
      await fetch(`/api/users/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      })
    } catch (err) {
      // Rollback
      set({ users: previous, error: err.message })
    }
  },

  deleteUser: async (id) => {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
    set((state) => ({ users: state.users.filter(u => u.id !== id) }))
  },
}))

// Component usage
function UserList() {
  const { users, loading, error, fetchUsers } = useUserStore(
    useShallow((s) => ({ users: s.users, loading: s.loading, error: s.error, fetchUsers: s.fetchUsers }))
  )

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

  if (loading) return <Spinner />
  if (error) return <p>Error: {error}</p>
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

Slice Pattern for Large Stores

As your application grows, a single massive store becomes unwieldy. The slice pattern splits the store into domain-specific slices that are combined into one root store. Each slice is an independent function that receives set, get, and the store — making slices portable and testable in isolation.

// slices/authSlice.js
export const createAuthSlice = (set, get) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    const user = await authApi.login(credentials)
    set({ user, isAuthenticated: true })
  },
  logout: () => {
    authApi.logout()
    set({ user: null, isAuthenticated: false })
    // Reset other slices too
    get().clearCart?.()
  },
})

// slices/cartSlice.js
export const createCartSlice = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })),
  clearCart: () => set({ items: [] }),
})

// slices/uiSlice.js
export const createUiSlice = (set) => ({
  theme: 'dark',
  sidebarOpen: false,
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
})

// Root store — combine all slices
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

export const useStore = create(
  devtools(
    persist(
      (...args) => ({
        ...createAuthSlice(...args),
        ...createCartSlice(...args),
        ...createUiSlice(...args),
      }),
      { name: 'app-store', partialize: (state) => ({ user: state.user, theme: state.theme }) }
    )
  )
)

Middleware: devtools, immer, persist

Zustand's middleware system wraps the create call. Stack multiple middlewares by nesting them. The three most useful are devtools (Redux DevTools integration), persist (localStorage/sessionStorage sync), and immer (mutable draft state syntax for complex nested updates).

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

// All three middleware stacked
const useStore = create(
  devtools(
    persist(
      immer((set) => ({
        todos: [],
        addTodo: (text) => set((state) => {
          // Immer: mutate draft directly — no spread needed
          state.todos.push({ id: Date.now(), text, done: false })
        }),
        toggleTodo: (id) => set((state) => {
          const todo = state.todos.find(t => t.id === id)
          if (todo) todo.done = !todo.done
        }),
        removeTodo: (id) => set((state) => {
          state.todos = state.todos.filter(t => t.id !== id)
        }),
      })),
      {
        name: 'todos-storage',
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({ todos: state.todos }),  // only persist todos
        version: 1,
        migrate: (persisted, version) => {
          if (version === 0) return { ...persisted, todos: [] }
          return persisted
        },
      }
    ),
    { name: 'TodoStore' }  // name shown in Redux DevTools
  )
)

Accessing Store Outside React

One of Zustand's biggest advantages over Context is that you can read and write the store from anywhere — event handlers, utility functions, WebSocket listeners, service workers — without hooks.

// Access store outside React components
import { useStore } from './store'

// Read current state
const state = useStore.getState()
console.log(state.user)

// Update state
useStore.setState({ theme: 'light' })
useStore.setState((state) => ({ count: state.count + 1 }))

// Subscribe to changes (returns unsubscribe function)
const unsubscribe = useStore.subscribe(
  (state) => state.user,    // selector
  (user, previousUser) => { // listener
    if (user && !previousUser) {
      console.log('User logged in:', user.name)
      analytics.track('login', { userId: user.id })
    }
  }
)

// WebSocket handler updating store without React
socket.on('notification', (data) => {
  useStore.setState((state) => ({
    notifications: [data, ...state.notifications],
  }))
})

// In an axios interceptor
axios.interceptors.response.use(
  null,
  (error) => {
    if (error.response?.status === 401) {
      useStore.getState().logout()
    }
    return Promise.reject(error)
  }
)

TypeScript Patterns

Zustand has excellent TypeScript support. Define an interface for your store shape and pass it as a generic to create. The StateCreator type enables fully typed slice functions with cross-slice access.

import { create, StateCreator } from 'zustand'

interface CounterState {
  count: number
  step: number
  increment: () => void
  decrement: () => void
  setStep: (step: number) => void
  reset: () => void
}

const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  step: 1,
  increment: () => set((s) => ({ count: s.count + s.step })),
  decrement: () => set((s) => ({ count: s.count - s.step })),
  setStep: (step) => set({ step }),
  reset: () => set({ count: 0, step: 1 }),
}))

// Typed slice pattern
interface AuthSlice {
  user: User | null
  login: (creds: Credentials) => Promise<void>
  logout: () => void
}

interface CartSlice {
  items: CartItem[]
  addItem: (item: Product) => void
  clearCart: () => void
}

type AppStore = AuthSlice & CartSlice

type SliceCreator<T> = StateCreator<AppStore, [], [], T>

const createAuthSlice: SliceCreator<AuthSlice> = (set) => ({
  user: null,
  login: async (creds) => {
    const user = await authApi.login(creds)
    set({ user })
  },
  logout: () => set({ user: null }),
})

const useAppStore = create<AppStore>()((...args) => ({
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
}))

Zustand vs Context vs Redux

Context + useReducer is fine for low-frequency updates (theme, auth) but causes performance problems with high-frequency state because every Context consumer re-renders. Zustand solves this with selector-based subscriptions and no Provider overhead. Redux remains the right choice for very large teams that need strict conventions, time-travel debugging, and ecosystem tooling. Zustand is the sweet spot for most production applications: minimal setup, excellent performance, and full DevTools support.

// Decision guide
//
// useState / useReducer  — local component state, no sharing needed
// Context + useReducer   — low-frequency global state (theme, auth session)
// Zustand               — shared UI state, shopping carts, multi-step forms
// TanStack Query         — server/async data (NOT a Zustand use case)
// Redux Toolkit          — large teams, complex state, strict conventions needed
//
// Zustand store size guide:
// Small app    — 1 store with slices
// Medium app   — 1 store with 3–5 slices
// Large app    — domain stores (useAuthStore, useCartStore, useUIStore) separately
//
// Reset store on logout
const resetStores = () => {
  useAuthStore.setState(authInitialState)
  useCartStore.setState(cartInitialState)
  useUIStore.setState(uiInitialState)
}

// Testing Zustand stores
describe('cartStore', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [] })  // reset between tests
  })

  it('adds item to cart', () => {
    useCartStore.getState().addItem({ id: 1, name: 'Book', price: 9.99 })
    expect(useCartStore.getState().items).toHaveLength(1)
  })
})