
Zustand
- 99 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
zustand is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- zustand
- AI & Agent Building
- AI-coding skill
Zustand by the numbers
- 99 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,419 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill zustandAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 99 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Zustand State Management
Overview
Zustand v5 is a lightweight global state manager for React built on useSyncExternalStore. Requires React 18+ (uses useSyncExternalStore internally). When using createWithEqualityFn, install use-sync-external-store as a peer dependency. It provides type-safe stores, atomic selectors for minimal re-renders, composable middleware (persist, devtools, immer, subscribeWithSelector), and a slices pattern for large applications. Use Zustand for client-only global state; use TanStack Query for server-fetched data.
When to use: Client-side global state, persistent user preferences, complex multi-domain stores, cross-component state sharing, vanilla (non-React) state management.
When NOT to use: Server state with caching needs (use TanStack Query), single-component state (use useState), simple prop drilling scenarios.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Basic store | create<T>()((set) => ({...})) | Double parentheses required for TS |
| Atomic selector | useStore(state => state.bears) | Only re-renders when selected value changes |
| Multiple values | useShallow(state => ({a, b})) | Import from zustand/react/shallow |
| Persist | persist(fn, { name }) | localStorage with SSR hydration handling |
| Devtools | devtools(fn, { name }) | Redux DevTools integration |
| SubscribeWithSelector | subscribeWithSelector(fn) | Subscribe to state slices outside React |
| Middleware order | devtools(persist(fn)) | Outer to inner wrapping |
| Slices pattern | StateCreator<Combined, [], [], Slice> | Split store by domain |
| SSR provider | createStore + React Context | Per-request stores prevent data leaks |
| Immer | immer(fn) | Safe nested state mutations |
| Vanilla store | createStore from zustand/vanilla | Use outside React |
| Reset store | set(store.getInitialState()) | Use getInitialState() for reliable reset |
| Derived values | useStore(s => s.items.length) | Compute in selector |
| Auto selectors | createSelectors(store) | Generate store.use.field() hooks automatically |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using create<T>(...) with single parentheses | Use create<T>()(...) double parentheses for correct TypeScript middleware inference |
Selecting full state object with useStore(state => state) | Use atomic selectors like state.bears or useShallow to avoid unnecessary re-renders |
Importing useShallow from zustand/shallow | Import from zustand/react/shallow; zustand/shallow exports the plain shallow function only |
| Creating new object references in selectors causing infinite loops | Use separate primitive selectors or wrap with useShallow from zustand/react/shallow |
| Using a global singleton store with SSR or Next.js | Use the StoreContext provider pattern with createStore to prevent data leaks between requests |
| Tracking initial state manually for reset | Use store.getInitialState() which Zustand provides automatically |
| Using Zustand for server-fetched data that needs caching and revalidation | Use TanStack Query for server state; reserve Zustand for client-only global state |
Delegation
- Store architecture and slices design: Use
Planagent to define domain boundaries, slice interfaces, and middleware composition order - Hydration and SSR debugging: Use
Taskagent to diagnose persist middleware issues, hydration mismatches, and Next.js provider setup - Migration from v4 to v5: Use
Exploreagent to find deprecated import paths, single-parentheses patterns, and legacy middleware usage - Testing strategy: Use
Taskagent to set up store mocks and test isolation patterns
References
- Store patterns, TypeScript, and core API
- Middleware: persist, devtools, immer, subscribeWithSelector, and custom
- Slices pattern and large store architecture
- SSR, Next.js, and hydration handling
- Testing stores with Vitest and Jest
- Migration guide: Redux, Context, and v4 to v5
Middleware
Persist Middleware
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useStore = create<UserPreferences>()(
persist(
(set) => ({
theme: 'system',
setTheme: (theme) => set({ theme }),
}),
{
name: 'user-preferences',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme }),
},
),
);Persist Options
| Option | Description |
|---|---|
name | Unique storage key (required) |
storage | Storage engine via createJSONStorage |
partialize | Only save specific fields |
version | Schema version for migrations |
migrate | Function to transform old state |
skipHydration | Manual hydration control |
onRehydrateStorage | Callback when hydration completes |
Schema Versioning and Migration
persist(
(set) => ({
/* ... */
}),
{
name: 'app-storage',
version: 2,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
persistedState.position = { x: persistedState.x, y: persistedState.y };
delete persistedState.x;
delete persistedState.y;
}
if (version === 1) {
return { ...persistedState, newField: 'default' };
}
return persistedState;
},
},
);Partial Persistence
persist((set) => ({ user: null, theme: 'dark', temp: 0 }), {
name: 'settings',
partialize: (state) => ({ theme: state.theme }),
});Custom Storage with Superjson
For complex types like Map, Set, Date, and RegExp:
import superjson from 'superjson';
import { type PersistStorage } from 'zustand/middleware';
interface AppState {
cache: Map<string, string>;
tags: Set<string>;
lastUpdated: Date;
}
const storage: PersistStorage<AppState> = {
getItem: (name) => {
const str = localStorage.getItem(name);
if (!str) return null;
return superjson.parse(str);
},
setItem: (name, value) => {
localStorage.setItem(name, superjson.stringify(value));
},
removeItem: (name) => localStorage.removeItem(name),
};
const useStore = create<AppState>()(
persist(
(set) => ({
cache: new Map(),
tags: new Set(),
lastUpdated: new Date(),
}),
{ name: 'app-storage', storage },
),
);Devtools Middleware
import { devtools } from 'zustand/middleware';
const useStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set((s) => ({ count: s.count + 1 }), undefined, 'increment'),
}),
{ name: 'CounterStore' },
),
);The third argument to set is the action name shown in Redux DevTools.
Immer Middleware
For safe nested state mutations:
import { immer } from 'zustand/middleware/immer';
interface Todo {
id: string;
title: string;
done: boolean;
}
type TodoStore = {
todos: Record<string, Todo>;
toggleTodo: (todoId: string) => void;
};
const useStore = create<TodoStore>()(
immer((set) => ({
todos: {},
toggleTodo: (todoId) =>
set((state) => {
state.todos[todoId].done = !state.todos[todoId].done;
}),
})),
);SubscribeWithSelector Middleware
Subscribe to specific state slices outside React components:
import { createStore } from 'zustand/vanilla';
import { subscribeWithSelector } from 'zustand/middleware';
type PositionStore = {
position: { x: number; y: number };
setPosition: (pos: { x: number; y: number }) => void;
};
const store = createStore<PositionStore>()(
subscribeWithSelector((set) => ({
position: { x: 0, y: 0 },
setPosition: (position) => set({ position }),
})),
);
// Subscribe to a slice of state
store.subscribe(
(state) => state.position,
(position) => console.log('Position changed:', position),
);
// Subscribe to a nested value
store.subscribe(
(state) => state.position.x,
(x) => console.log('X changed:', x),
);Combining Middlewares (Order Matters)
const useStore = create<MyStore>()(
devtools(
persist(
(set) => ({
/* state */
}),
{ name: 'storage' },
),
{ name: 'MyStore' },
),
);Outer middleware wraps inner. Devtools should be outermost for debugging visibility.
Custom Middleware
import { type StateCreator, type StoreMutatorIdentifier } from 'zustand';
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
f: StateCreator<T, Mps, Mcs>,
name?: string,
) => StateCreator<T, Mps, Mcs>;
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...a) => {
set(...a);
console.log(`[${name ?? 'store'}]:`, get());
};
return f(loggedSet, get, store);
};TypeScript with Middleware
Slices with Middleware Mutators
import { type StateCreator } from 'zustand';
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[['zustand/devtools', never]],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () =>
set((state) => ({ bears: state.bears + 1 }), undefined, 'bear/add'),
});Migration Guide
From Redux to Zustand
Before (Redux)
// Action types, actions, reducer, store, provider, useSelector, useDispatch
const INCREMENT = 'INCREMENT';
const increment = () => ({ type: INCREMENT });
const reducer = (state = { count: 0 }, action) => {
switch (action.type) {
case INCREMENT:
return { count: state.count + 1 };
default:
return state;
}
};
const store = createStore(reducer);
// Component
const count = useSelector((state) => state.count);
const dispatch = useDispatch();After (Zustand)
import { create } from 'zustand';
interface Store {
count: number;
increment: () => void;
}
const useStore = create<Store>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Component - no provider needed!
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);Benefits: ~90% less boilerplate, no provider wrapper, no action types/creators, built-in TypeScript.
Redux to Zustand Mapping
| Redux | Zustand |
|---|---|
| Actions | Direct functions in store |
| Action types | Not needed |
| Reducers | Inline in set() calls |
useSelector | Direct store selectors |
useDispatch | Direct function calls |
| Provider | Not needed |
| Middleware | Built-in (persist, devtools) |
| DevTools | devtools middleware |
From Context API to Zustand
Before (Context)
const CountContext = createContext(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={{ count, increment: () => setCount((c) => c + 1) }}>
{children}
</CountContext.Provider>
);
}
function useCount() {
const context = useContext(CountContext);
if (!context) throw new Error('useCount must be within CountProvider');
return context;
}After (Zustand)
const useStore = create<Store>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// No provider, no context null checks
const count = useStore((state) => state.count);Benefits: No provider, no null checks, better performance (no context re-renders).
From Zustand v4 to v5
Breaking Changes
1. TypeScript Double Parentheses
// v4
const useStore = create<Store>((set) => ({
/* ... */
}));
// v5 - double parentheses required
const useStore = create<Store>()((set) => ({
/* ... */
}));2. Persist Middleware
// v4
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
/* ... */
}),
{ name: 'storage' },
),
);
// v5 - explicit createJSONStorage
import { persist, createJSONStorage } from 'zustand/middleware';
const useStore = create<Store>()(
persist(
(set) => ({
/* ... */
}),
{
name: 'storage',
storage: createJSONStorage(() => localStorage),
},
),
);3. useShallow Import Path
// v4
import shallow from 'zustand/shallow';
// v5 - shallow comparison function (named export)
import { shallow } from 'zustand/shallow';
// v5 - useShallow hook (different import path)
import { useShallow } from 'zustand/react/shallow';Note: zustand/shallow exports the plain shallow comparison function. The useShallow React hook is at zustand/react/shallow.
4. Devtools Import Path
In v5, devtools is exported from 'zustand/middleware' (not 'zustand/middleware/devtools').
5. Immer Import
import { immer } from 'zustand/middleware/immer';Zustand v5 Key Changes
1. Native `useSyncExternalStore`: Full concurrent rendering support, zero tearing 2. Smaller bundle: Dropped legacy support 3. Improved TypeScript: Native support for combined stores and middleware 4. Context-Store pattern: Official SSR standard to prevent data leakage 5. Manual rehydration control: skipHydration: true for fine-grained persist timing 6. `getInitialState()`: Stores expose initial state for reliable resets
Migration Strategies
Gradual Migration (Recommended for Large Apps)
1. Install Zustand alongside existing solution 2. Migrate one feature at a time 3. Test thoroughly before moving to next 4. Remove old code once stable
Migration Checklist
- Installed Zustand v5+
- Created store with
create<T>()() - Removed Context providers (if migrating from Context)
- Removed Redux boilerplate (if migrating from Redux)
- Updated all
useSelectorto Zustand selectors - Updated all
useDispatchto direct function calls - Updated
useShallowimports tozustand/react/shallow - Added
persistif state needs persistence - Added
devtoolsif using Redux DevTools - Tested all components
- Verified no hydration errors (Next.js)
- Removed old state management code
Slices Pattern
For large applications, a single store file becomes unmaintainable. The Slices Pattern splits your store into functional modules while maintaining a single cohesive state tree.
Defining Slices
A slice is a function that returns a part of the state and actions:
// src/stores/slices/user-slice.ts
import { type StateCreator } from 'zustand';
export interface UserSlice {
name: string;
setName: (name: string) => void;
}
export const createUserSlice: StateCreator<
UserSlice & AuthSlice,
[],
[],
UserSlice
> = (set) => ({
name: '',
setName: (name) => set({ name }),
});The StateCreator type parameters:
1. Combined store type (all slices) 2. Middleware mutators (empty if none) 3. Chained middleware (empty if none) 4. This slice type
Combining Slices
// src/stores/root-store.ts
import { create } from 'zustand';
import { createUserSlice, type UserSlice } from './slices/user-slice';
import { createAuthSlice, type AuthSlice } from './slices/auth-slice';
export type RootStore = UserSlice & AuthSlice;
export const useStore = create<RootStore>()((...a) => ({
...createUserSlice(...a),
...createAuthSlice(...a),
}));Cross-Slice Access
Because all slices share the same set and get, you can interact with other slices:
export interface AuthSlice {
isLoggedIn: boolean;
login: () => void;
}
export const createAuthSlice: StateCreator<UserSlice & AuthSlice> = (
set,
get,
) => ({
isLoggedIn: false,
login: () => {
set({ isLoggedIn: true });
console.log(`User ${get().name} logged in`);
},
});Shared Slices
Create slices that compose actions from other slices:
import { create, type StateCreator } from 'zustand';
interface BearSlice {
bears: number;
addBear: () => void;
eatFish: () => void;
}
interface FishSlice {
fishes: number;
addFish: () => void;
}
interface SharedSlice {
addBoth: () => void;
getBoth: () => number;
}
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
});
const createFishSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
FishSlice
> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
});
const createSharedSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
SharedSlice
> = (_set, get) => ({
addBoth: () => {
get().addBear();
get().addFish();
},
getBoth: () => get().bears + get().fishes,
});
const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a),
}));Slices with Middleware
When using middleware, add mutators to StateCreator:
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[['zustand/devtools', never]],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () =>
set((state) => ({ bears: state.bears + 1 }), undefined, 'bear/add'),
});Circular Reference Fix
Define combined type first, then reference in slices:
type AllSlices = BearSlice & FishSlice & SharedSlice;
const createBearSlice: StateCreator<AllSlices, [], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
});Best Practices
- Atomic Actions: Keep actions close to the data they modify
- Type Safety: Use
StateCreatortype to ensure slices access the combined store type - Avoid Duplication: Do not repeat state keys across slices
- File Organization: One slice per file in
stores/slices/ - Naming: Name slices by domain (user, auth, cart), not by feature
- Shared Slices: Use a shared slice for cross-cutting actions that coordinate between domains
SSR and Hydration
The Problem
In SSR, creating a store as a global singleton is dangerous because it persists in memory across multiple requests, leading to data leakage between users.
Provider Pattern (Ref-based)
Create a store instance per request and share via React Context:
// src/providers/store-provider.tsx
'use client';
import { createContext, useContext, useRef, type ReactNode } from 'react';
import { useStore } from 'zustand';
import { createCounterStore, type CounterStore } from '@/stores/counter-store';
export const CounterStoreContext = createContext<CounterStore | null>(null);
export const CounterStoreProvider = ({ children }: { children: ReactNode }) => {
const storeRef = useRef<CounterStore>(undefined);
if (!storeRef.current) {
storeRef.current = createCounterStore();
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
);
};
export const useCounterStore = <T,>(
selector: (store: CounterStore) => T,
): T => {
const counterStoreContext = useContext(CounterStoreContext);
if (!counterStoreContext) {
throw new Error(`useCounterStore must be used within CounterStoreProvider`);
}
return useStore(counterStoreContext, selector);
};Why useRef?
useRef prevents the store from being re-created if the Provider re-renders. This is crucial for state stability on the client.
Initializing State from Server Props
Pass initial data from a Server Component to the store via the Provider:
export const createCounterStore = (initState: Partial<CounterState> = {}) => {
return createStore<CounterStore>()((set) => ({
count: 0,
...initState,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
};
export const CounterStoreProvider = ({
children,
initialCount,
}: {
children: React.ReactNode;
initialCount: number;
}) => {
const storeRef = useRef<CounterStore>(undefined);
if (!storeRef.current) {
storeRef.current = createCounterStore({ count: initialCount });
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
);
};Scoped Stores with Dynamic Keys
For multi-instance scenarios (tabs, panels), use a Map to hold store instances:
'use client';
import {
type ReactNode,
useState,
useCallback,
useContext,
createContext,
} from 'react';
import { createStore, useStore } from 'zustand';
const StoresContext = createContext<Map<
string,
ReturnType<typeof createCounterStore>
> | null>(null);
export const StoresProvider = ({ children }: { children: ReactNode }) => {
const [stores] = useState(
() => new Map<string, ReturnType<typeof createCounterStore>>(),
);
return (
<StoresContext.Provider value={stores}>{children}</StoresContext.Provider>
);
};
export const useScopedStore = <T,>(
key: string,
selector: (state: CounterState) => T,
): T => {
const stores = useContext(StoresContext);
if (!stores) {
throw new Error('useScopedStore must be used within StoresProvider');
}
const getOrCreate = useCallback(() => {
if (!stores.has(key)) {
stores.set(key, createCounterStore());
}
return stores.get(key)!;
}, [stores, key]);
return useStore(getOrCreate(), selector);
};Persist Hydration in Next.js
Because localStorage only exists on the client, the server renders with initial state while the client renders with persisted state, causing a hydration mismatch.
Fix: \_hasHydrated Flag
interface StoreWithHydration {
count: number;
_hasHydrated: boolean;
setHasHydrated: (hydrated: boolean) => void;
}
const useStore = create<StoreWithHydration>()(
persist(
(set) => ({
count: 0,
_hasHydrated: false,
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
}),
{
name: 'my-store',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
},
),
);
// In component - render fallback until hydrated
function MyComponent() {
const hasHydrated = useStore((state) => state._hasHydrated);
if (!hasHydrated) return <div>Loading...</div>;
return <ActualContent />;
}Fix: skipHydration
Manually trigger hydration in a useEffect:
const useStore = create<MyStore>()(
persist(
(set) => ({
/* ... */
}),
{
name: 'app-storage',
skipHydration: true,
},
),
);
// In client component
import { useEffect } from 'react';
export function MyComponent() {
useEffect(() => {
useStore.persist.rehydrate();
}, []);
// ...
}Troubleshooting
Hydration Mismatch
Error: "Text content does not match server-rendered HTML"
Cause: Persist middleware reads localStorage on client but not server.
Fix: Use _hasHydrated flag with onRehydrateStorage or skipHydration.
Persist Import Error
Error: "'createJSONStorage' is not exported from 'zustand/middleware'"
Fix: Ensure zustand v5+ and correct import:
import { persist, createJSONStorage } from 'zustand/middleware';Key Rules
- Never read Zustand stores in React Server Components
- Pass data via props from Server to Client components
- Use
createStore(notcreate) with the provider pattern - Each SSR request must get its own store instance
- Use
useRef(notuseState) to hold the store reference in providers
Store Patterns and TypeScript
Basic TypeScript Store
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 })),
}));
// In components - only re-renders when bears changes
const bears = useBearStore((state) => state.bears);
const increase = useBearStore((state) => state.increase);The Double-Parentheses Rule
// Bad - breaks middleware type inference
const useStore = create<MyStore>((set) => ({
/* ... */
}));
// Good - always use double parentheses
const useStore = create<MyStore>()((set) => ({
/* ... */
}));The currying syntax create<T>()() enables middleware type inference in TypeScript. Always use it even without middleware for future-proofing.
Store Interface Pattern
Separate state from actions for clarity:
interface BearState {
bears: number;
}
interface BearActions {
increase: (by: number) => void;
decrease: (by: number) => void;
}
type BearStore = BearState & BearActions;
const useBearStore = create<BearStore>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
decrease: (by) => set((state) => ({ bears: state.bears - by })),
}));Selectors
Atomic Selectors (Preferred)
const bears = useStore((state) => state.bears);
const increase = useStore((state) => state.increase);Multiple Values with useShallow
// Bad - new object every render, causes infinite re-renders in v5
const { bears, fishes } = useStore((state) => ({
bears: state.bears,
fishes: state.fishes,
}));
// Good - separate selectors
const bears = useStore((state) => state.bears);
const fishes = useStore((state) => state.fishes);
// Good - useShallow for multiple values
import { useShallow } from 'zustand/react/shallow';
const { bears, fishes } = useStore(
useShallow((state) => ({ bears: state.bears, fishes: state.fishes })),
);useShallow performs shallow comparison on the selector output, preventing re-renders when the selected values have not changed.
Computed/Derived Selectors
const count = useStore((state) => state.items.length);
// Parameterized selector
const selectById = (id: string) => (state: Store) =>
state.items.find((item) => item.id === id);
const item = useStore(selectById('123'));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 });
},
}));Reset Store with getInitialState
Use store.getInitialState() which Zustand provides automatically:
const useStore = create<State & Actions>()((set, get, store) => ({
count: 0,
name: '',
reset: () => {
set(store.getInitialState());
},
}));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 Hook with Types
import { createStore, useStore } from 'zustand';
const bearStore = createStore<BearStore>()((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}));
function useBearStore<T>(selector: (state: BearStore) => T): T {
return useStore(bearStore, selector);
}Auto-Generating Selectors
Create typed store.use.field() hooks automatically instead of writing selectors manually:
import { type StoreApi, useStore, createStore } from 'zustand';
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
const createSelectors = <S extends StoreApi<object>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {} as any;
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () =>
useStore(_store, (s) => s[k as keyof typeof s]);
}
return store;
};
interface BearState {
bears: number;
increase: (by: number) => void;
increment: () => void;
}
const store = createStore<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
increment: () => set((state) => ({ bears: state.bears + 1 })),
}));
const useBearStore = createSelectors(store);
// Usage - no manual selector needed
const bears = useBearStore.use.bears();
const increment = useBearStore.use.increment();Direct State Mutation Anti-Pattern
// Bad
set((state) => {
state.count++;
return state;
});
// Good - immutable update
set((state) => ({ count: state.count + 1 }));
// Good - use immer middleware for complex nested stateTesting Stores
Testing with Vitest
Create a fresh store instance per test to prevent state leakage:
import { describe, it, expect, beforeEach } from 'vitest';
import { createStore } from 'zustand/vanilla';
interface CounterStore {
count: number;
increment: () => void;
}
function createCounterStore() {
return createStore<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
}
describe('Counter Store', () => {
let store: ReturnType<typeof createCounterStore>;
beforeEach(() => {
store = createCounterStore();
});
it('starts at zero', () => {
expect(store.getState().count).toBe(0);
});
it('increments count', () => {
store.getState().increment();
expect(store.getState().count).toBe(1);
});
it('increments multiple times', () => {
store.getState().increment();
store.getState().increment();
expect(store.getState().count).toBe(2);
});
});Jest Mock for Global Stores
When stores are defined as module-level singletons, mock Zustand to auto-reset after each test:
// __mocks__/zustand.ts
import { act } from '@testing-library/react';
import type * as ZustandExportedTypes from 'zustand';
export * from 'zustand';
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof ZustandExportedTypes>('zustand');
export const storeResetFns = new Set<() => void>();
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreate(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried;
}) as typeof ZustandExportedTypes.create;
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof ZustandExportedTypes.createStore;
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn();
});
});
});Place this file at __mocks__/zustand.ts (or .js) in your project root. Jest will auto-discover it via module name mapping.
Vitest Mock Equivalent
// __mocks__/zustand.ts
import { act } from '@testing-library/react';
import type * as ZustandExportedTypes from 'zustand';
const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual<typeof ZustandExportedTypes>('zustand');
export * from 'zustand';
export const storeResetFns = new Set<() => void>();
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreate(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried;
}) as typeof ZustandExportedTypes.create;
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof ZustandExportedTypes.createStore;
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn();
});
});
});For Vitest, configure the mock in vitest.config.ts:
export default defineConfig({
test: {
setupFiles: ['./setup-tests.ts'],
},
});Testing Async Actions
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createStore } from 'zustand/vanilla';
interface AsyncStore {
data: string | null;
isLoading: boolean;
fetchData: () => Promise<void>;
}
function createAsyncStore() {
return createStore<AsyncStore>()((set) => ({
data: null,
isLoading: false,
fetchData: async () => {
set({ isLoading: true });
const response = await fetch('/api/data');
set({ data: await response.text(), isLoading: false });
},
}));
}
describe('Async Store', () => {
let store: ReturnType<typeof createAsyncStore>;
beforeEach(() => {
store = createAsyncStore();
vi.restoreAllMocks();
});
it('fetches data', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('test data'));
await store.getState().fetchData();
expect(store.getState().data).toBe('test data');
expect(store.getState().isLoading).toBe(false);
});
it('sets loading state', async () => {
vi.spyOn(global, 'fetch').mockImplementation(() => new Promise(() => {}));
const promise = store.getState().fetchData();
expect(store.getState().isLoading).toBe(true);
});
});Testing with Subscribe
it('notifies subscribers on state change', () => {
const store = createCounterStore();
const listener = vi.fn();
store.subscribe(listener);
store.getState().increment();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ count: 1 }),
expect.objectContaining({ count: 0 }),
);
});Resetting State in Tests
Use getInitialState() for reliable resets:
it('resets to initial state', () => {
const store = createCounterStore();
store.getState().increment();
store.getState().increment();
expect(store.getState().count).toBe(2);
store.setState(store.getInitialState(), true);
expect(store.getState().count).toBe(0);
});The second argument true to setState replaces the state entirely instead of merging.