Zustand: Microstate Management
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
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
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 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
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
useStore(s => s.x)) to avoid rerenders on unrelated changes.shallow (or a custom equality) to prevent rerenders.immer middleware.loading + error deterministically.persist is client-only; avoid SSR assumptions in Next.js App Router.