ReactZustand

Zustand

Zustand is a minimal, fast state management library for React. Unlike Redux, there is no boilerplate — no actions, no reducers, no Provider wrapping your tree. You define a store in a single create() call, export a hook, and call that hook anywhere in your app.

Install it with:

Bash
npm install zustand
Creating Your First Store

create() accepts a function that receives set and returns an object containing both state values and the actions that update them. The returned value is a hook you use directly in components — no Provider needed.

TS
import { create } from 'zustand'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => 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 }),
}))

Consuming the store in a component is straightforward. Pass a selector function to the hook to subscribe only to the slice of state you need — this prevents unnecessary re-renders:

TSX
function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)
  const reset = useCounterStore((state) => state.reset)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}
Note
Each component that calls useCounterStore re-renders only when the selected slice of state changes. Selecting state.count does not re-render when an unrelated part of the store updates.
A Real-World Cart Store

Here is a complete shopping cart store. It manages a list of items and exposes add, remove, and a derived total — all in one place:

TS
import { create } from 'zustand'

interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: number) => void
  updateQuantity: (id: number, quantity: number) => void
  clearCart: () => void
  total: () => number
}

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

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

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

  updateQuantity: (id, quantity) =>
    set((state) => ({
      items: state.items.map((i) =>
        i.id === id ? { ...i, quantity } : i
      ),
    })),

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

  // get() reads current state without subscribing
  total: () =>
    get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}))

TSX
function CartSummary() {
  const items = useCartStore((state) => state.items)
  const total = useCartStore((state) => state.total)
  const removeItem = useCartStore((state) => state.removeItem)

  if (items.length === 0) return <p>Your cart is empty.</p>

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name} × {item.quantity}</span>
          <span>${(item.price * item.quantity).toFixed(2)}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <strong>Total: ${total().toFixed(2)}</strong>
    </div>
  )
}
Async Actions

Actions in Zustand are plain functions — async ones work without any special middleware. Call set after your await completes:

TS
interface UserState {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: number) => Promise<void>
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,

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

Wrap your store with devtools from zustand/middleware to get full Redux DevTools support — time-travel debugging, action logs, and state snapshots:

TS
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, 'increment'),
      decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
      reset: () => set({ count: 0 }, false, 'reset'),
    }),
    { name: 'CounterStore' }
  )
)
Note
The third argument to set() is an action label shown in Redux DevTools. Labelling actions makes it much easier to trace which action caused a state change.
Persist Middleware (localStorage)

The persist middleware serializes your store to localStorage automatically. On page load, it rehydrates from the saved value:

TS
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set((state) => ({ /* ... */ })),
      removeItem: (id) => set((state) => ({ /* ... */ })),
      total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
    }),
    {
      name: 'cart-storage',   // localStorage key
      // Optionally persist only a subset of state:
      partialize: (state) => ({ items: state.items }),
    }
  )
)
Slices Pattern for Large Stores

When a store grows large, split it into slices. Each slice is a function that receives set and get and returns its portion of state. Combine them with create:

TS
// slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
  items: [] as CartItem[],
  addItem: (item) => set((state) => ({ /* ... */ })),
  removeItem: (id) => set((state) => ({ /* ... */ })),
})

// slices/userSlice.ts
export const createUserSlice = (set) => ({
  user: null as User | null,
  setUser: (user: User) => set({ user }),
  logout: () => set({ user: null }),
})

// store.ts
import { create } from 'zustand'
import { createCartSlice } from './slices/cartSlice'
import { createUserSlice } from './slices/userSlice'

export const useStore = create((set, get) => ({
  ...createCartSlice(set, get),
  ...createUserSlice(set),
}))
Zustand vs Redux — Why Developers Switch

Concern

Redux Toolkit

Zustand

Setup

store + slices + Provider

One create() call

Boilerplate

createSlice, createAsyncThunk

Plain functions

Provider

Required (<Provider store={store}>)

Not required

Bundle size

~47 kB

~3 kB

DevTools

Built-in excellent support

Via devtools middleware

Learning curve

Moderate (flux mental model)

Low

Best for

Large teams, strict structure

Most apps

  • No Provider means you can use the store in non-React contexts (event handlers, utilities, server code).

  • Selecting primitives from the store keeps re-renders minimal without useMemo or shallowEqual.

  • Combine with devtools + persist for production-grade stores with zero extra packages.

  • The slices pattern scales to large apps while keeping files focused.

Tip
Start with a single create() store and extract slices only when the file gets unwieldy (typically 200+ lines). Premature splitting adds complexity without benefit.
Warning
Avoid reading store state outside of selectors (e.g. useCartStore.getState() inside a render). This bypasses React's reactivity and leads to stale UI. Call getState() only in event handlers or async functions.