SuperAPI AI Directory

zustand-state-management

Frontend & websites

View repository

Updated 10-15 21:42

About this skill

LLM reference: Fetch llms.txt for the documentation index and latest API. Full docs: llms-full.txt. Human-readable docs: zustand.docs.pmnd.rs.

Core mental model: Working with Zustand (Dominik Dorfmeister) — export custom hooks only, atomic selectors, actions namespace, event-style actions, many small stores, combine with Query/URL via custom hooks.

Version: Zustand v5 (create<T>()(), useShallow from zustand/shallow). Pin zustand@^5 in the app; check migrating to v5 when upgrading.


When to use Zustand

Use Zustand for Use something else for
Client UI state shared across components (panels, map tools, wizard steps) Server data → TanStack Query
Ephemeral client prefs not in the URL Shareable/bookmarkable state → nuqs
Small, synchronous client state Auth session → app auth skill / Better Auth

Most app state is server or URL state. Add a Zustand store only when you need client-global state that is not already covered.


File and export naming

One store file = one concern. Colocate with the feature (see tanstack-start-conventions for Start layout; SPA apps: feature folders under components/ / shared/).

Item Convention Example
File {domain}-store.ts (kebab-case, -store suffix) map-filters-store.ts
Folder Next to the feature, e.g. src/features/map/ not a single global stores/ dump unless shared
State type {Domain}Store (PascalCase interface) MapFiltersStore
Internal create hook use{Domain}Store — never exported useMapFiltersStore
Exported hooks use{Slice} for state primitives; use{Domain}Actions for actions useAppliedFilters, useMapFiltersActions

Do not use: store.ts, state.ts, zustand.ts, or useMapFiltersStore.ts (filename must not imply exporting the raw store). Do not put unrelated domains in one file.

Persist storage key (if used): unique string aligned with the file, e.g. fmc-map-filters for map-filters-store.ts. See llms.txt → persist.


Canonical store shape

import { create } from 'zustand'

interface MapFiltersStore {
  applied: string[]
  actions: {
    addFilter: (filter: string) => void
    clearFilters: () => void
  }
}

const useMapFiltersStore = create<MapFiltersStore>()((set) => ({
  applied: [],
  actions: {
    addFilter: (filter) =>
      set((state) => (state.applied.includes(filter) ? state : { applied: [...state.applied, filter] })),
    clearFilters: () => set({ applied: [] }),
  },
}))

export const useAppliedFilters = () => useMapFiltersStore((state) => state.applied)

export const useMapFiltersActions = () => useMapFiltersStore((state) => state.actions)

TypeScript: Always create<StoreType>()((set) => …) (double parentheses), even without middleware — see llms.txt → beginner TypeScript guide.

In components:

const filters = useAppliedFilters()
const { addFilter } = useMapFiltersActions() // actions object is stable; destructuring is fine

Practices (from Tkdodo + Zustand docs)

1. Only export custom hooks

Keep useMapFiltersStore private. Consumers must not call useStore() without a selector — that subscribes to the entire store and re-renders on any change.

// ❌ if exported: subscribes to everything
const { applied } = useMapFiltersStore()

2. Prefer atomic selectors

Selectors are compared with strict equality. Returning a new object/array every time always counts as a change.

// ❌ new object every render
useMapFiltersStore((s) => ({ applied: s.applied, count: s.applied.length }))

// ✅ separate hooks (preferred)
export const useAppliedFilters = () => useMapFiltersStore((s) => s.applied)
export const useAppliedFilterCount = () => useMapFiltersStore((s) => s.applied.length)

If two primitives are needed together rarely, use useShallow — see llms.txt → prevent rerenders with useShallow:

import { useShallow } from 'zustand/shallow'

const { applied, count } = useMapFiltersStore(useShallow((s) => ({ applied: s.applied, count: s.applied.length })))

Prefer atomic hooks over useShallow when practical.

3. Separate actions from state

Put mutators under actions. The actions reference never changes, so useMapFiltersActions() is one stable subscription; destructuring addFilter inside a component is safe.

4. Model actions as events, not setters

Name and implement commands in the store (addFilter, closePanel), not setApplied from components. Business logic stays in the store; components call actions.

5. Many small stores

Prefer several {domain}-store.ts files over one app-wide store. Combine at the edges with custom hooks:

export const useCurrentUserProfile = () => {
  const userId = useCredentialsStore((s) => s.currentUserId)
  return useUsersStore((s) => (userId ? s.users[userId] : undefined))
}

Avoid the slices pattern unless you have a strong reason — TypeScript gets heavy; see llms.txt → slices pattern. Tkdodo’s default is separate stores.

6. Combine with TanStack Query / URL state

export const useFilteredTodos = () => {
  const filters = useAppliedFilters()
  return useQuery({
    queryKey: ['todos', filters],
    queryFn: () => getTodos(filters),
  })
}

Filters live in Zustand; server list lives in Query. URL filters belong in nuqs, not duplicated in Zustand.

7. Idempotent updates (optional)

When inputs fire often (resize, observers), return previous state from set when nothing effectively changed so subscribers are not notified:

updateSize: (next) =>
  set((state) =>
    state.size.width === next.width && state.size.height === next.height ? state : { size: next },
  ),

Critical rules

Always

  • create<T>()() in TypeScript
  • Export only named selector hooks + use{Domain}Actions
  • Atomic selectors returning primitives (or stable references)
  • actions namespace for mutators
  • Immutable updates via set; use updater form set((state) => …) for derived patches
  • Unique persist name per store
  • For Next.js / SSR + persist: follow llms.txt → Setup with Next.js / SSR and Hydration (hydration gate or official SSR-safe patterns)

Never

  • Export the raw use*Store from create
  • useStore() with no selector (whole-store subscription)
  • Object/array selectors without useShallow
  • Zustand for server-fetched data
  • Mutate state in place inside set
  • Duplicate URL state in Zustand when nuqs already owns it

Common pitfalls (short)

Symptom Likely cause Fix
Re-render on unrelated updates Whole-store subscription or object selector Atomic exported hooks
Maximum update depth exceeded Object selector creating new reference each time Atomic hooks or useShallow
Next.js hydration mismatch persist + SSR llms.txt Next.js / SSR guides
Middleware types break create<T>(…) single parens create<T>()(…)
Wrong persist import Importing from 'zustand' import { persist } from 'zustand/middleware'

Delegate to llms.txt

Do not duplicate full API docs in this skill. Fetch llms.txt when you need:

  • Middleware: persist, devtools, immer, combining middlewares
  • Patterns: slices, reset state, vanilla store, testing, flux-style reducers
  • Integrations: Next.js, URL hash, Maps/Sets
  • Migration: v4 → v5 (useShallow import paths, etc.)

Official guides worth opening directly: Updating state, Flux inspired practice.


Related skills

Topic Skill
App layout, SSR (Start) tanstack-start-conventions
URL search (Router) tanstack-router-conventions
URL query state nuqs