Back to React & React Native

zustand-state-management

zustandreactstate managementtypescriptfrontendjavascripthooksweb development
⭐ 860πŸ“„ MITπŸ•’ 2026-06-11Source β†—

Install this skill

npx skills add jezweb/claude-skills

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

Zustand is a minimalist state management library for React and React Native. It operates on a subscription model, allowing components to selectively re-render only when the specific slice of state they consume changes. Unlike Context API, it does not require provider wrapping. Its API centers around a hook-based store that remains accessible anywhere in the component tree. Zustand emphasizes immutable updates and strictly separated actions, ensuring predictable data flow. The library supports middleware for persistence and devtools integration while maintaining a tiny footprint. By leveraging the curried create function, it provides strong TypeScript inference, preventing common type-related bugs. This library is effective for managing everything from simple UI toggles to complex global state without the boilerplate often associated with Redux.

When to Use This Skill

  • β€’Maintaining user authentication tokens across a SPA
  • β€’Synchronizing UI theme preferences across sessions
  • β€’Handling complex form state that persists during navigation
  • β€’Managing real-time dashboard data updates
  • β€’Sharing state between deeply nested components without prop drilling

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œHow to manage global state in React without Context
  • β€œSetup Zustand with TypeScript correctly
  • β€œPersist Zustand store to localStorage in Next.js
  • β€œPrevent unnecessary re-renders in Zustand
  • β€œZustand store vs TanStack Query for data fetching

Pro Tips

  • πŸ’‘Always use the `create<T>()()` double parentheses syntax for TypeScript stores to ensure robust type inference and safety.
  • πŸ’‘Leverage selector functions (`useStore(state => state.value)`) to prevent unnecessary component re-renders, ensuring components only update when their specific slice of state changes.
  • πŸ’‘For complex asynchronous actions or side effects, consider integrating middleware or custom actions directly within your store to keep logic centralized and testable.

What this skill does

  • β€’Atomic state selection to prevent unnecessary component re-renders
  • β€’Persist middleware for automatic synchronization with browser storage
  • β€’Middleware support for logging, devtools, and hydration tracking
  • β€’Curried TypeScript store creation for accurate type inference
  • β€’Decoupled store architecture allowing non-hook state access

When not to use it

  • βœ•Managing server-side cache and remote data fetching
  • βœ•Storing massive datasets that require frequent high-frequency updates
  • βœ•Standard local component state that doesn't need to be shared

Example workflow

  1. Define a TypeScript interface representing the store state and actions
  2. Create the store using the curried create function to ensure type safety
  3. Implement store actions as pure functions that return updated state
  4. Use selector functions within components to target specific state slices
  5. Apply the persist middleware to automatically save state to browser storage
  6. Handle hydration checks in Next.js to prevent mismatch errors during SSR

Prerequisites

  • –React 18 or 19
  • –TypeScript 5.0+

Pitfalls & limitations

  • !Selecting entire objects instead of specific properties causes performance issues
  • !Accessing localStorage in server-rendered environments leads to hydration errors
  • !Direct state mutation bypassing the set function creates silent bugs
  • !Using single parentheses in TypeScript breaks middleware type inference

FAQ

Why use double parentheses in the create function?
The double parentheses syntax is required to satisfy TypeScript's inference for middleware, preventing type loss when wrapping the store creator.
Is Zustand a replacement for TanStack Query?
No. Zustand is for client-side state, while TanStack Query is specialized for server-state caching, synchronization, and data fetching.
How do I prevent re-renders in components?
Always use a selector function to pick the smallest necessary slice of state; if picking multiple values, use the useShallow hook.

How it compares

Unlike manual Context API implementations, Zustand minimizes component re-renders through targeted subscriptions and avoids the deep tree nesting required by Providers.

Source & trust

⭐ 860 starsπŸ“„ MITπŸ•’ Updated 2026-06-11
πŸ“„ Full skill instructions β€” original source: jezweb/claude-skills
# Zustand State Management

**Last Updated**: 2026-01-21
**Latest Version**: [email protected] (released 2026-01-12)
**Dependencies**: React 18-19, TypeScript 5+

---

## Quick Start

npm install zustand


**TypeScript Store** (CRITICAL: use create<T>()() double parentheses):
import { create } from 'zustand'

interface BearStore {
bears: number
increase: (by: number) => void
}

const useBearStore = create<BearStore>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))


**Use in Components**:
const bears = useBearStore((state) => state.bears)  // Only re-renders when bears changes
const increase = useBearStore((state) => state.increase)


---

## Core Patterns

**Basic Store** (JavaScript):
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))


**TypeScript Store** (Recommended):
interface CounterStore { count: number; increment: () => void }
const useStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))


**Persistent Store** (survives page reloads):
import { persist, createJSONStorage } from 'zustand/middleware'

const useStore = create<UserPreferences>()(
persist(
(set) => ({ theme: 'system', setTheme: (theme) => set({ theme }) }),
{ name: 'user-preferences', storage: createJSONStorage(() => localStorage) },
),
)


---

## Critical Rules

### Always Do

βœ… Use create<T>()() (double parentheses) in TypeScript for middleware compatibility
βœ… Define separate interfaces for state and actions
βœ… Use selector functions to extract specific state slices
βœ… Use set with updater functions for derived state: set((state) => ({ count: state.count + 1 }))
βœ… Use unique names for persist middleware storage keys
βœ… Handle Next.js hydration with hasHydrated flag pattern
βœ… Use useShallow hook for selecting multiple values
βœ… Keep actions pure (no side effects except state updates)

### Never Do

❌ Use create<T>(...) (single parentheses) in TypeScript - breaks middleware types
❌ Mutate state directly: set((state) => { state.count++; return state }) - use immutable updates
❌ Create new objects in selectors: useStore((state) => ({ a: state.a })) - causes infinite renders
❌ Use same storage name for multiple stores - causes data collisions
❌ Access localStorage during SSR without hydration check
❌ Use Zustand for server state - use TanStack Query instead
❌ Export store instance directly - always export the hook

---

## Known Issues Prevention

This skill prevents **6** documented issues:

### Issue #1: Next.js Hydration Mismatch

**Error**: "Text content does not match server-rendered HTML" or "Hydration failed"

**Source**:
- [DEV Community: Persist middleware in Next.js](https://dev.to/abdulsamad/how-to-use-zustands-persist-middleware-in-nextjs-4lb5)
- GitHub Discussions #2839

**Why It Happens**:
Persist middleware reads from localStorage on client but not on server, causing state mismatch.

**Prevention**:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface StoreWithHydration {
count: number
_hasHydrated: boolean
setHasHydrated: (hydrated: boolean) => void
increase: () => void
}

const useStore = create<StoreWithHydration>()(
persist(
(set) => ({
count: 0,
_hasHydrated: false,
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
increase: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'my-store',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
},
),
)

// In component
function MyComponent() {
const hasHydrated = useStore((state) => state._hasHydrated)

if (!hasHydrated) {
return <div>Loading...</div>
}

// Now safe to render with persisted state
return <ActualContent />
}


### Issue #2: TypeScript Double Parentheses Missing

**Error**: Type inference fails, StateCreator types break with middleware

**Source**: [Official Zustand TypeScript Guide](https://zustand.docs.pmnd.rs/guides/typescript)

**Why It Happens**:
The currying syntax create<T>()() is required for middleware to work with TypeScript inference.

**Prevention**:
// ❌ WRONG - Single parentheses
const useStore = create<MyStore>((set) => ({
// ...
}))

// βœ… CORRECT - Double parentheses
const useStore = create<MyStore>()((set) => ({
// ...
}))


**Rule**: Always use create<T>()() in TypeScript, even without middleware (future-proof).

### Issue #3: Persist Middleware Import Error

**Error**: "Attempted import error: 'createJSONStorage' is not exported from 'zustand/middleware'"

**Source**: GitHub Discussion #2839

**Why It Happens**:
Wrong import path or version mismatch between zustand and build tools.

**Prevention**:
// βœ… CORRECT imports for v5
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

// Verify versions
// [email protected] includes createJSONStorage
// [email protected] uses different API

// Check your package.json
// "zustand": "^5.0.9"


### Issue #4: Infinite Render Loop

**Error**: Component re-renders infinitely, browser freezes
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.


**Source**:
- GitHub Discussions #2642
- [Issue #2863](https://github.com/pmndrs/zustand/issues/2863)

**Why It Happens**:
Creating new object references in selectors causes Zustand to think state changed.

**v5 Breaking Change**: Zustand v5 made this error MORE explicit compared to v4. In v4, this behavior was "non-ideal" but could go unnoticed. In v5, you'll immediately see the "Maximum update depth exceeded" error.

**Prevention**:
import { useShallow } from 'zustand/shallow'

// ❌ WRONG - Creates new object every time
const { bears, fishes } = useStore((state) => ({
bears: state.bears,
fishes: state.fishes,
}))

// βœ… CORRECT Option 1 - Select primitives separately
const bears = useStore((state) => state.bears)
const fishes = useStore((state) => state.fishes)

// βœ… CORRECT Option 2 - Use useShallow hook for multiple values
const { bears, fishes } = useStore(
useShallow((state) => ({ bears: state.bears, fishes: state.fishes }))
)


### Issue #5: Slices Pattern TypeScript Complexity

**Error**: StateCreator types fail to infer, complex middleware types break

**Source**: [Official Slices Pattern Guide](https://github.com/pmndrs/zustand/blob/main/docs/guides/slices-pattern.md)

**Why It Happens**:
Combining multiple slices requires explicit type annotations for middleware compatibility.

**Prevention**:
import { create, StateCreator } from 'zustand'

// Define slice types
interface BearSlice {
bears: number
addBear: () => void
}

interface FishSlice {
fishes: number
addFish: () => void
}

// Create slices with proper types
const createBearSlice: StateCreator<
BearSlice & FishSlice, // Combined store type
[], // Middleware mutators (empty if none)
[], // Chained middleware (empty if none)
BearSlice // This slice's type
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})

const createFishSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
FishSlice
> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

// Combine slices
const useStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))


### Issue #6: Persist Middleware Race Condition (Fixed v5.0.10+)

**Error**: Inconsistent state during concurrent rehydration attempts

**Source**:
- [GitHub PR #3336](https://github.com/pmndrs/zustand/pull/3336)
- [Release v5.0.10](https://github.com/pmndrs/zustand/releases/tag/v5.0.10)

**Why It Happens**:
In Zustand v5.0.9 and earlier, concurrent calls to rehydrate during persist middleware initialization could cause a race condition where multiple hydration attempts would interfere with each other, leading to inconsistent state.

**Prevention**:
Upgrade to Zustand v5.0.10 or later. No code changes needed - the fix is internal to the persist middleware.

npm install zustand@latest  # Ensure v5.0.10+


**Note**: This was fixed in v5.0.10 (January 2026). If you're using v5.0.9 or earlier and experiencing state inconsistencies with persist middleware, upgrade immediately.

---

## Middleware

**Persist** (localStorage):
import { persist, createJSONStorage } from 'zustand/middleware'

const useStore = create<MyStore>()(
persist(
(set) => ({ data: [], addItem: (item) => set((state) => ({ data: [...state.data, item] })) }),
{
name: 'my-storage',
partialize: (state) => ({ data: state.data }), // Only persist 'data'
},
),
)


**Devtools** (Redux DevTools):
import { devtools } from 'zustand/middleware'

const useStore = create<CounterStore>()(
devtools(
(set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 }), undefined, 'increment') }),
{ name: 'CounterStore' },
),
)


**v4β†’v5 Migration Note**: In Zustand v4, devtools was imported from 'zustand/middleware/devtools'. In v5, use 'zustand/middleware' (as shown above). If you see "Module not found: Can't resolve 'zustand/middleware/devtools'", update your import path.

**Combining Middlewares** (order matters):
const useStore = create<MyStore>()(devtools(persist((set) => ({ /* ... */ }), { name: 'storage' }), { name: 'MyStore' }))


---

## Common Patterns

**Computed/Derived Values** (in selector, not stored):
const count = useStore((state) => state.items.length)  // Computed on read


**Async Actions**:
const useAsyncStore = create<AsyncStore>()((set) => ({
data: null,
isLoading: false,
fetchData: async () => {
set({ isLoading: true })
const response = await fetch('/api/data')
set({ data: await response.text(), isLoading: false })
},
}))


**Resetting Store**:
const initialState = { count: 0, name: '' }
const useStore = create<ResettableStore>()((set) => ({
...initialState,
reset: () => set(initialState),
}))


**Selector with Params**:
const todo = useStore((state) => state.todos.find((t) => t.id === id))


---

## Bundled Resources

**Templates**: basic-store.ts, typescript-store.ts, persist-store.ts, slices-pattern.ts, devtools-store.ts, nextjs-store.ts, computed-store.ts, async-actions-store.ts

**References**: middleware-guide.md (persist/devtools/immer/custom), typescript-patterns.md (type inference issues), nextjs-hydration.md (SSR/hydration), migration-guide.md (from Redux/Context/v4)

**Scripts**: check-versions.sh (version compatibility)

---

## Advanced Topics

**Vanilla Store** (Without React):
import { createStore } from 'zustand/vanilla'

const store = createStore<CounterStore>()((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })) }))
const unsubscribe = store.subscribe((state) => console.log(state.count))
store.getState().increment()


**Custom Middleware**:
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...a) => { set(...a); console.log([${name}]:, get()) }
return f(loggedSet, get, store)
}


**Immer Middleware** (Mutable Updates):
import { immer } from 'zustand/middleware/immer'

const useStore = create<TodoStore>()(immer((set) => ({
todos: [],
addTodo: (text) => set((state) => { state.todos.push({ id: Date.now().toString(), text }) }),
})))


**v5.0.3β†’v5.0.4 Migration Note**: If upgrading from v5.0.3 to v5.0.4+ and immer middleware stops working, verify you're using the import path shown above (zustand/middleware/immer). Some users reported issues after the v5.0.4 update that were resolved by confirming the correct import.

**Experimental SSR Safe Middleware** (v5.0.9+):

**Status**: Experimental (API may change)

Zustand v5.0.9 introduced experimental unstable_ssrSafe middleware for Next.js usage. This provides an alternative approach to the _hasHydrated pattern (see Issue #1).

import { unstable_ssrSafe } from 'zustand/middleware'

const useStore = create<Store>()(
unstable_ssrSafe(
persist(
(set) => ({ /* state */ }),
{ name: 'my-store' }
)
)
)


**Recommendation**: Continue using the _hasHydrated pattern documented in Issue #1 until this API stabilizes. Monitor [Discussion #2740](https://github.com/pmndrs/zustand/discussions/2740) for updates on when this becomes stable.

---

## Official Documentation

- **Zustand**: https://zustand.docs.pmnd.rs/
- **GitHub**: https://github.com/pmndrs/zustand
- **TypeScript Guide**: https://zustand.docs.pmnd.rs/guides/typescript
- **Context7 Library ID**: /pmndrs/zustand


---

---
paths: "**/*.ts", "**/*.tsx", "**/*store*.ts", "**/*state*.ts"
---

# Zustand v5 Corrections

Claude's training may reference v4 patterns. This project uses **Zustand v5**.

## TypeScript: Double Parentheses Required

/* ❌ v4 syntax (breaks middleware types) */
const useStore = create<MyState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))

/* βœ… v5 syntax: create<T>()(...) */
const useStore = create<MyState>()((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))


## Persist: Import from Middleware

/* ❌ Wrong import */
import { persist, createJSONStorage } from 'zustand'

/* βœ… Import from middleware */
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useStore = create<MyState>()(
persist(
(set) => ({ /* ... */ }),
{
name: 'my-store',
storage: createJSONStorage(() => localStorage),
}
)
)


## Selectors: Don't Create Objects

/* ❌ Creates new object every render (infinite re-renders) */
const { count, increment } = useStore((s) => ({
count: s.count,
increment: s.increment,
}))

/* βœ… Option 1: Select separately */
const count = useStore((s) => s.count)
const increment = useStore((s) => s.increment)

/* βœ… Option 2: Use shallow comparator */
import { useShallow } from 'zustand/shallow'
const { count, increment } = useStore(
useShallow((s) => ({ count: s.count, increment: s.increment }))
)


## Next.js Hydration Mismatch

/* ❌ "Text content does not match" error */
// Persist reads localStorage on client, not server

/* βœ… Add hydration check */
interface MyState {
count: number
_hasHydrated: boolean
setHasHydrated: (state: boolean) => void
}

const useStore = create<MyState>()(
persist(
(set) => ({
count: 0,
_hasHydrated: false,
setHasHydrated: (state) => set({ _hasHydrated: state }),
}),
{
name: 'my-store',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
}
)
)

// In component:
const hasHydrated = useStore((s) => s._hasHydrated)
if (!hasHydrated) return <Loading />


## Slices Pattern: Explicit Types

/* βœ… Complex but necessary for middleware */
import { StateCreator } from 'zustand'

interface BearSlice {
bears: number
addBear: () => void
}

const createBearSlice: StateCreator<
BearSlice & FishSlice, // Combined state
[],
[],
BearSlice // This slice
> = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
})


## Quick Fixes

| If Claude suggests... | Use instead... |
|----------------------|----------------|
| create<T>((set) => ...) | create<T>()((set) => ...) |
| Import persist from 'zustand' | Import from 'zustand/middleware' |
| Object in selector | Select separately or use useShallow |
| Next.js hydration error | Add _hasHydrated pattern |
| Simple slices types | Use explicit StateCreator types |

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/zustand-state-management/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/jezweb/claude-skills/zustand-state-management/SKILL.md
  • Cursor: ~/.cursor/skills/jezweb/claude-skills/zustand-state-management/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/jezweb/claude-skills/zustand-state-management/SKILL.md

πŸš€ Install with CLI:
npx skills add jezweb/claude-skills

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid react & react native issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under React & React Native and is published by JezWeb, maintained in jezweb/claude-skills.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.