EmpireUI
Get Pro
← Blog8 min read#zustand#react#state management

Zustand in React: Simple State Management That Gets Out of Your Way

Zustand is the React state management library that's actually fun to use. No boilerplate, no providers — just a hook and a store. Here's everything you need.

Developer coding React state management on a laptop screen at night

Why Zustand Won (While Redux Got Tired)

Redux has been the default answer to React state management since 2015. And for a long time, that made sense. But here we are in 2026, and the community has largely moved on — at least for anything that isn't a massive enterprise app with strict predictability requirements. Zustand fills the gap Redux left for the rest of us.

Honestly, the thing that makes Zustand great is what it doesn't make you do. No Provider wrapping your app root. No action types. No reducers. No mapStateToProps. You write a store in one file, import a hook, done. That's not an oversimplification — that's actually all it is.

That said, Zustand isn't a toy. It handles derived state, middleware, devtools integration, and even persistence via plugins. It's just that none of that baggage is mandatory from the start. You add complexity when *you* need it, not because the library demands an architectural commitment on day one.

Worth noting: Zustand has been downloaded over 5 million times per week as of mid-2026. It's not a niche pick anymore. If you're using it, you're in good company.

Setting Up Your First Store

Install it. One command, nothing else.

npm install zustand

Now create a store. The create function from Zustand takes a callback that receives a set function and returns your initial state plus any actions you want to attach to it. Everything lives in one object.

import { create } from 'zustand'

interface CounterStore {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))

Using it in a component is just a hook call. No context, no HOC, no selector boilerplate — though you *can* use selectors for performance, which we'll get to.

import { useCounterStore } from '@/stores/counterStore'

export function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+1</button>
    </div>
  )
}

Quick aside: you're selecting from the store with a callback. That's intentional — Zustand only re-renders your component when the *selected slice* changes. Select the whole store object and you'll re-render on every state change. Select just count and you'll only re-render when count changes. Keep your selectors tight.

Async Actions, Derived State, and Slices

Real apps need async. Zustand handles it with zero extra ceremony — your action is just an async function. Call set whenever you're ready.

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

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      const res = await fetch(`/api/users/${id}`)
      const user = await res.json()
      set({ user, loading: false })
    } catch (err) {
      set({ error: 'Failed to load user', loading: false })
    }
  },
}))

Derived state is where people sometimes overthink it. You don't need a selector library or memoization plugins for most cases. Just compute derived values inside your component with useMemo, or expose a getter function from the store if multiple components need it.

For large apps, the slice pattern is your friend. Instead of one giant store object, you split state into logical chunks and compose them with create. Each slice gets its own file and its own type.

// stores/slices/cartSlice.ts
export interface CartSlice {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
}

export const createCartSlice = (set: SetState<AppStore>): CartSlice => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
})

// stores/appStore.ts
import { create } from 'zustand'
import { createCartSlice, CartSlice } from './slices/cartSlice'
import { createUserSlice, UserSlice } from './slices/userSlice'

type AppStore = CartSlice & UserSlice

export const useAppStore = create<AppStore>((set, get) => ({
  ...createCartSlice(set),
  ...createUserSlice(set, get),
}))

In practice, slices become necessary once your store crosses around 10-12 action functions. Before that, a single file is fine. Don't pre-optimize.

Middleware: Devtools, Persistence, and Immer

Zustand ships a middleware system that wraps your create call. The three you'll reach for most are devtools, persist, and immer.

Devtools hooks your store into the Redux DevTools browser extension. This gives you time-travel debugging and action history — invaluable when you're tracking down why a cart total is off by 8px... wait, that's a CSS problem. You know what I mean.

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

export const useStore = create(devtools((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
})))

Persist serializes your store to localStorage (or any custom storage) so it survives page reloads. Perfect for auth state, theme preferences, or shopping carts.

import { persist } from 'zustand/middleware'

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'dark' as 'dark' | 'light',
      setTheme: (theme: 'dark' | 'light') => set({ theme }),
    }),
    { name: 'theme-storage' } // localStorage key
  )
)

Immer lets you write mutations directly instead of spreading objects everywhere. If you're coming from Redux Toolkit, you'll feel right at home. Just wrap your create call with immer and you can write state.items.push(newItem) instead of [...state.items, newItem]. One more thing — you can compose middleware. Stack devtools(persist(immer(...))) and all three work together.

Zustand vs Context API: When to Use Which

This comes up constantly. Here's the honest answer: React Context is fine for low-frequency updates — theme toggling, locale, auth status. Things that change maybe once per session. The moment you're pushing updates at 60fps or sharing state between dozens of components that each need different slices, Context becomes a re-render machine.

Zustand avoids Context's re-render cascade because it's not built on Context internally (since v4). It uses React's useSyncExternalStore under the hood, which means subscriptions are granular. Only the components that *select* changed state re-render. That's a meaningful difference when you have a complex UI.

Look, there's no law against using both. Auth token in Context (barely changes, app-wide), UI interaction state in Zustand (changes constantly, component-scoped). Mix and match based on the update frequency of your data.

If you're building component-heavy interfaces — think templates or design-system explorers with real-time theming controls — Zustand's granular subscriptions will save you from constant profiling sessions. Context would force every themed component to re-render on any style change. Not ideal.

Performance Patterns You'll Actually Need

Selector discipline is everything. If you're doing this — const store = useStore() — you're subscribing to the entire store. Every state change re-renders your component. Do this instead: const count = useStore((s) => s.count). Slice. Always.

For components that need multiple pieces of state, you have two options. Either call useStore multiple times with different selectors (totally valid), or use Zustand's useShallow helper to do a shallow comparison on an object selector.

import { useShallow } from 'zustand/react/shallow'

const { count, user } = useStore(
  useShallow((s) => ({ count: s.count, user: s.user }))
)

Without useShallow, that object selector creates a new object reference on every render, defeating the purpose. With useShallow, it only re-renders when count or user actually change. This was introduced in Zustand v4.4 in 2023 and is the canonical way to select multiple values as of 2026.

One pattern worth adopting early: put all your store files in /stores and export *only* hooks, never the raw store object. This keeps consumers decoupled from the store shape. When you refactor, you update the store and the hook — not 30 component files.

Integrating Zustand With Your UI Components

State management and UI components have a symbiotic relationship. If you're building a themed component library — say, toggling between glassmorphism and neobrutalism styles dynamically — Zustand is a clean fit for holding the active style token.

Here's a real-world pattern: a useStyleStore that tracks which visual mode you're in, and components that read from it to apply the right class names or CSS variables. You'd connect it to style switcher UI from Empire UI, and every subscribed component updates instantly without prop drilling.

type StyleMode = 'glassmorphism' | 'neobrutalism' | 'claymorphism' | 'cyberpunk'

interface StyleStore {
  mode: StyleMode
  setMode: (mode: StyleMode) => void
}

export const useStyleStore = create<StyleStore>(persist(
  (set) => ({
    mode: 'glassmorphism',
    setMode: (mode) => set({ mode }),
  }),
  { name: 'empire-style-mode' }
))

// In your component
const mode = useStyleStore((s) => s.mode)
const cardClass = mode === 'glassmorphism'
  ? 'bg-white/10 backdrop-blur-md border border-white/20'
  : 'bg-white border-2 border-black shadow-[4px_4px_0px_black]'

That backdrop-blur pattern pairs naturally with the glassmorphism generator if you want to prototype the exact blur and opacity values before hardcoding them. Tweak the values there, paste the CSS variables into your store defaults.

The key thing to internalize: Zustand doesn't care about your component tree. Your store is global by default, and any component anywhere in your app can subscribe to any slice. That's either liberating or terrifying depending on your team size — but with selector discipline and the slice pattern, it scales further than people give it credit for.

FAQ

Does Zustand work with React Server Components in Next.js?

Zustand stores are client-side by default — they rely on hooks and browser APIs. You'll use them in Client Components ('use client') only. For RSC-compatible global state, stick to URL params or server-fetched data passed as props.

Can I use Zustand without TypeScript?

Yes, plain JavaScript works fine. Just drop the generic type parameter from create<MyStore>() and call it as create((set) => (...)). That said, TypeScript makes Zustand noticeably nicer — autocomplete on selectors alone is worth it.

How does Zustand compare to Jotai or Recoil?

Jotai and Recoil use an atom model — you define individual pieces of state rather than a single store object. Zustand's store model is simpler to reason about for most apps, especially when actions need to touch multiple state fields in one update.

Does `persist` middleware work with SSR?

Sort of. The store hydrates from localStorage on the client, which means there's a brief moment where server-rendered HTML and client state don't match. Use Zustand's onRehydrateStorage callback to track hydration state and conditionally render until it's done.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Zustand Guide 2026: Slices, Immer Middleware, DevTools, PersistJotai Guide 2026: Atoms, Derived State, Async Atoms, Molecule PatternZustand vs Jotai in 2026: Atomic vs Single Store StateZustand vs Valtio: Proxy State vs Vanilla Store in React