| name | zustand |
| description | [Applies to: **/*.{js,jsx}] Definitive guidelines for using Zustand in React projects, focusing on type safety, modularity, performance, and maintainability with practical code examples. |
| source | cursor_mdc |
zustand Best Practices
Zustand is our go-to for global state management due to its minimal API and built-in performance optimizations. This guide outlines the definitive patterns for using Zustand effectively in our projects, ensuring type safety, modularity, and optimal performance.
1. Typed Store Shape (TypeScript First)
Always define explicit TypeScript interfaces for your store's state and actions. This provides invaluable type safety and auto-completion across the application, preventing common runtime errors.
❌ BAD: Untyped store, prone to runtime errors
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
✅ GOOD: Fully typed store for robust development
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
2. Slice-Based Organization
For scalable applications, organize your store into logical "slices" (e.g., authSlice, uiSlice). This keeps concerns separated, improves readability, and makes testing easier. Compose these slices into a single root store.
src/store/types.ts:
import { StateCreator } from 'zustand';
export interface AuthSlice {
user: { id: string; name: string } | null;
token: string | null;
login: (user: { id: string; name: string }, token: string) => void;
logout: () => void;
}
export interface UISlice {
isLoading: boolean;
setLoading: (loading: boolean) => void;
}
export type AppState = AuthSlice & UISlice;
export type AppStateCreator<T> = StateCreator<AppState, [], [], T>;
src/store/slices/createAuthSlice.ts:
import { AppStateCreator, AuthSlice } from '../types';
export const createAuthSlice: AppStateCreator<AuthSlice> = (set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
});
src/store/slices/createUISlice.ts:
import { AppStateCreator, UISlice } from '../types';
export const createUISlice: AppStateCreator<UISlice> = (set) => ({
isLoading: false,
setLoading: (loading) => set({ isLoading: loading }),
});
src/store/useAppStore.ts:
import { create } from 'zustand';
import { AppState } from './types';
import { createAuthSlice } from './slices/createAuthSlice';
import { createUISlice } from './slices/createUISlice';
export const useAppStore = create<AppState>()((...a) => ({
...createAuthSlice(...a),
...createUISlice(...a),
}));
3. Naming Conventions
Follow consistent naming for clarity and discoverability.
- Store Hook:
use[Feature]Store or useAppStore for the root.
- Actions: Verb-oriented (e.g.,
increment, setUser, fetchData).
- Store Files: Located under
src/store/ or src/features/[feature]/store.ts.
- Exports: Only export the custom hook, never the raw
create object.
❌ BAD: Inconsistent naming, exposing raw store
export const myStore = create(...);
export const useMyStore = myStore;
myStore.setState({ ... });
✅ GOOD: Clear, consistent, and encapsulated
export const useAppStore = create<AppState>(...);
export const useAuthStore = create<AuthSlice>(...);
import { useAppStore } from 'src/store/useAppStore';
const { user, login } = useAppStore();
4. Functional Updates to Prevent Stale Closures
Always use functional updates (set(state => ...)) when an action's new state depends on the current state. This prevents issues with stale closures in asynchronous operations or rapid updates.
❌ BAD: Potential stale closure, especially in async operations
const useCounterStore = create<CounterState>((set, get) => ({
count: 0,
incrementAsync: async () => {
await someAsyncOperation();
const currentCount = get().count;
set({ count: currentCount + 1 });
},
}));
✅ GOOD: Robust functional update, state is always the latest
const useCounterStore = create<CounterState>((set) => ({
count: 0,
incrementAsync: async () => {
await someAsyncOperation();
set((state) => ({ count: state.count + 1 }));
},
}));
5. Selectors and Shallow Comparison for Performance
Consume only the necessary parts of the state using selectors. For objects or arrays, use shallow (or a custom equality function) to prevent unnecessary re-renders when only nested properties change.
❌ BAD: Re-renders component on any state change in the store
const MyComponent = () => {
const { user, token } = useAppStore();
};
✅ GOOD: Optimized re-renders with selectors and shallow
import { shallow } from 'zustand/shallow';
import { useAppStore } from 'src/store/useAppStore';
const UserProfile = () => {
const { name, email } = useAppStore(
(state) => ({ name: state.user?.name, email: state.user?.email }),
shallow
);
};
const AuthStatus = () => {
const token = useAppStore((state) => state.token);
};
6. Essential Middleware Usage
Leverage Zustand's middleware for common concerns like persistence, devtools integration, and immutable updates.
persist: For local storage or IndexedDB. Always provide a name and consider version for migrations.
devtools: Integrate with Redux DevTools. Enable only in development.
immer: For simplified immutable updates, especially with deeply nested objects.
import { create } from 'zustand';
import { persist, devtools, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface SettingsState {
theme: 'light' | 'dark';
notifications: { enabled: boolean; sound: boolean };
setTheme: (theme: 'light' | 'dark') => void;
toggleNotifications: () => void;
toggleNotificationSound: () => void;
}
const useSettingsStore = create<SettingsState>()(
devtools(
persist(
immer((set) => ({
theme: 'light',
notifications: { enabled: true, sound: true },
setTheme: (theme) => ({ theme }),
:
( {
state.. = !state..;
}),
:
( {
state.. = !state..;
}),
})),
{
: ,
: ( ),
: ,
: ({ : state. }),
}
),
{ : , : process.. === }
)
);
7. Initializing Stores Outside Components
Declare your create calls at the module level (top of the file) to ensure a single, consistent store instance across your application. Never call create inside a React component.
❌ BAD: Creates new store instance on every render
const MyComponent = () => {
const useLocalStore = create(() => ({ value: 0 }));
const value = useLocalStore((state) => state.value);
return <div>{value}</div>;
};
✅ GOOD: Single store instance, declared once
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
const MyComponent = () => {
const count = useCounterStore((state) => state.count);
return <div>{count}</div>;
};
8. Asynchronous Actions
Handle asynchronous operations directly within your store actions. Zustand doesn't require special middleware for async, keeping the API simple and direct.
interface UserState {
user: { id: string; name: string } | null;
loading: boolean;
error: string | null;
fetchUser: (userId: string) => Promise<void>;
}
const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (userId) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed