Zustand: Microstate Management

Medium

Zustand is a hooks-first global state library that scales well when you follow two rules: use selectors (don’t subscribe to the whole store) and be explicit about equality when selecting objects/arrays. It shines for UI and shared client state, and integrates cleanly with TypeScript and middleware like persist and devtools.

Quick Decision Guide

### Quick Setup

1) Create a store

import { create } from 'zustand'

export const useCounterStore = create((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 })
}))

2) Use selectors (recommended)

const count = useCounterStore((s) => s.count)
const inc = useCounterStore((s) => s.inc)

Avoid (re-renders on any store change):

const state = useCounterStore()

Object selections: use shallow

import { shallow } from 'zustand/shallow'

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc }),
  shallow
)

Next.js App Router: keep stores in "use client" modules; only persist on client.

When to use: global-ish state without Redux boilerplate; UI state + shared client state.

Big idea: selectors + equality control rerenders.

Store Creation (State + Actions)

Basic Store

A Zustand store is a hook created by create. You return a single object containing state and actions.

import { create } from 'zustand'

export const useAuthStore = create((set, get) => ({
  user: null,
  isAuthenticated: false,

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

  // `get()` lets you read current state (careful: still keep updates immutable)
  toggleAuth: () => set({ isAuthenticated: !get().isAuthenticated })
}))

Nested / Immutable Updates

Zustand doesn't magically deep-clone. If you update nested objects, update immutably:

set((s) => ({
  user: s.user ? { ...s.user, name: 'John' } : null
}))

If you want mutation-style ergonomics, use immer middleware (see Advanced).

Selectors & Re-render Performance (Most Important)

Why Selectors Matter

Zustand re-renders a component when the selected value changes.

❌ Anti-pattern: subscribe to the whole store

const state = useAuthStore() // rerenders when ANY field changes

✅ Best practice: select only what you need

const user = useAuthStore((s) => s.user)
const login = useAuthStore((s) => s.login)

Selecting multiple fields

When you return an object, you'll create a new object every render unless you use an equality fn.

import { shallow } from 'zustand/shallow'

const { user, isAuthenticated } = useAuthStore(
  (s) => ({ user: s.user, isAuthenticated: s.isAuthenticated }),
  shallow
)

Rule of thumb

select primitives/functions directly
for objects/arrays, use shallow (or a custom equality)

Advanced subscription (non-React)

For performant subscriptions outside React components:

subscribeWithSelector middleware (great for analytics/logging/reacting to specific fields)

Async Actions Pattern (Loading/Error)

Async Actions

A clean interview-grade pattern: store loading + error and update deterministically.

export const useUserStore = create((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('Request failed')
      const user = await res.json()
      set({ user, loading: false })
    } catch (e) {
      set({ error: e?.message ?? 'Unknown error', loading: false })
    }
  }
}))

Common pitfall

forgetting to reset error/loading on retries
storing promises in state
updating state after unmount without guarding (depends on your fetch strategy)

Middleware (persist, devtools, immer) + Slicing

Middleware

`persist` (client-side storage)

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

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme })
    }),
    { name: 'theme-storage' }
  )
)

`devtools`

import { devtools } from 'zustand/middleware'

export const useCounterStore = create(
  devtools((set) => ({
    count: 0,
    inc: () => set((s) => ({ count: s.count + 1 }), false, 'counter/inc')
  }))
)

`immer` for nested updates

import { immer } from 'zustand/middleware/immer'

export const useProfileStore = create(
  immer((set) => ({
    profile: { name: 'A', skills: [] },
    addSkill: (skill) =>
      set((s) => {
        s.profile.skills.push(skill)
      })
  }))
)

Slicing / Store Organization

For larger apps, either:

multiple stores (simplest)
or slice composition (one store with modules)

Multiple stores example:

export const useUserStore = create((set) => ({ user: null, setUser: (u) => set({ user: u }) }))
export const useUIStore = create((set) => ({ modalOpen: false, setModalOpen: (v) => set({ modalOpen: v }) }))

Pitfall

Don’t over-slice too early; start small and split when the store becomes noisy.

Next.js App Router Gotchas (Practical)

Next.js (App Router) Notes

Zustand stores are client-side state.
Keep store modules in files used only by Client Components.
Avoid creating a store inside a component (would reset on every render).
persist uses localStorage → only runs on client; guard access if needed.

Rule: store hook should be a module-level singleton.

// ✅ good: module singleton
export const useStore = create(...)

// ❌ bad: recreated per render
function Page() {
  const useStore = create(...)
}

Key Takeaways

1Zustand is best for shared client/UI state without provider boilerplate.
2Use selectors (useStore(s => s.x)) to avoid rerenders on unrelated changes.
3If selecting objects/arrays, use shallow (or a custom equality) to prevent rerenders.
4Nested updates must be immutable unless using immer middleware.
5Async actions should manage loading + error deterministically.
6persist is client-only; avoid SSR assumptions in Next.js App Router.