zustand — Cursor rule: what it does, how to install
Enforces Zustand store conventions in React projects, covering TypeScript typings, slice architecture, shallow selectors, and middleware setups.
.cursor/rules/zustand.mdcWhat zustand does
The rule mandates explicit TypeScript interfaces for store state and actions, flagging untyped stores as unsafe. For scalable projects, it enforces a slice-based architecture where individual creators like createAuthSlice and createUISlice merge into a central useAppStore.
Direct store mutations outside custom hooks are blocked: components must only consume hooks. When an action depends on previous state, the rule requires functional updates set((state) => ...) to avoid stale closures during async operations. It also promotes fine-grained selectors paired with shallow to prevent unnecessary component re-renders.
What it helps with
- split large global stores into modular feature slices
- add strict TypeScript interfaces to state and actions
- stop unnecessary component re-renders using selectors with shallow
- eliminate stale closure bugs in async actions
Example prompts for the agent
- Create an auth slice in src/store/slices/createAuthSlice.ts with login and logout actions
- Refactor useCounterStore to use functional updates for async increment
- Subscribe component to user properties using useAppStore and shallow
How to install zustand
Manually: put the files in place
.cursor/rules/zustand.mdcFrontmatter: description, globs, alwaysApply
Manually: put the files in place
.claude/rules/zustand.mdThe paths frontmatter field limits the files: move globs from the source into paths
AGENTS.md
Codex reads AGENTS.md: paste the rule text there
Manually: put the files in place
.github/instructions/zustand.instructions.mdThe applyTo field sets which files it applies to: move globs from the source into applyTo
Manually: put the files in place
.devin/rules/zustand.mdManually: put the files in place
.clinerules/zustand.mdStep by step, with commands for each platform: Cursor rules: .mdc files, four modes and what replaced .cursorrules
Questions about zustand
How to install zustand in Cursor?
Place the rule file at .cursor/rules/zustand.mdc in the root of your repository.
What patterns does this rule enforce?
It sets standards for modular slice patterns, strict TypeScript types, selector usage with shallow, and middleware like persist or immer.
Why does the rule forbid exporting raw store instances?
Calling setState directly on the raw store bypasses React lifecycle updates, so the rule restricts exports strictly to custom React hooks.
Source text
zustand.mdc · CC0-1.0
---
description: Definitive guidelines for using Zustand in React projects, focusing on type safety, modularity, performance, and maintainability with practical code examples.
globs: **/*.{js,jsx}
---
# 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
```typescript
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
```
✅ GOOD: Fully typed store for robust development
```typescript
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`:
```typescript
import { StateCreator } from 'zustand';
// Define individual slice interfaces
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;
}
// Combine all slice interfaces into the global AppState
export type AppState = AuthSlice & UISlice;
// Type for creating slices that will be composed into AppState
export type AppStateCreator<T> = StateCreator<AppState, [], [], T>;
```
`src/store/slices/createAuthSlice.ts`:
```typescript
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`:
```typescript
import { AppStateCreator, UISlice } from '../types';
export const createUISlice: AppStateCreator<UISlice> = (set) => ({
isLoading: false,
setLoading: (loading) => set({ isLoading: loading }),
});
```
`src/store/useAppStore.ts`:
```typescript
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
```typescript
// store.js
export const myStore = create(...); // Exposes raw store
export const useMyStore = myStore; // Bad naming, use `useMyStore` as the primary hook
// component.jsx
myStore.setState({ ... }); // Direct mutation outside hook, bypasses React lifecycle
```
✅ GOOD: Clear, consistent, and encapsulated
```typescript
// src/store/useAppStore.ts
export const useAppStore = create<AppState>(...);
// src/features/auth/useAuthStore.ts (if using feature-specific stores)
export const useAuthStore = create<AuthSlice>(...);
// component.tsx
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
```typescript
const useCounterStore = create<CounterState>((set, get) => ({
count: 0,
// ...
incrementAsync: async () => {
await someAsyncOperation();
const currentCount = get().count; // 'currentCount' might be outdated if another update occurred
set({ count: currentCount + 1 });
},
}));
```
✅ GOOD: Robust functional update, `state` is always the latest
```typescript
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
```typescript
const MyComponent = () => {
const { user, token } = useAppStore(); // Re-renders if any part of AppState changes
// ...
};
```
✅ GOOD: Optimized re-renders with selectors and `shallow`
```typescript
import { shallow } from 'zustand/shallow';
import { useAppStore } from 'src/store/useAppStore';
const UserProfile = () => {
// Only re-renders if user.name or user.email changes
const { name, email } = useAppStore(
(state) => ({ name: state.user?.name, email: state.user?.email }),
shallow
);
// ...
};
const AuthStatus = () => {
// Only re-renders if token changes
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.
```typescript
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( // Enable devtools for debugging
persist( // Persist state to storage
immer((set) => ({ // Use immer for easier immutable updates
theme: 'light',
notifications: { enabled: true, sound: true },
setTheme: (theme) => set({ theme }),
toggleNotifications: () =>
set((state) => {
state.notifications.enabled = !state.notifications.enabled; // Direct mutation with immer
}),
toggleNotificationSound: () =>
set((state) => {
state.notifications.sound = !state.notifications.sound; // Direct mutation with immer
}),
})),
{
name: 'app-settings', // Unique name for storage key
storage: createJSONStorage(() => localStorage), // Choose storage type
version: 1, // Crucial for future migrations
partialize: (state) => ({ theme: state.theme }), // Only persist 'theme'
}
),
{ name: 'Settings Store', enabled: process.env.NODE_ENV === 'development' } // Devtools options
)
);
```
## 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
```typescript
const MyComponent = () => {
// This creates a NEW store instance every time MyComponent re-renders!
const useLocalStore = create(() => ({ value: 0 }));
const value = useLocalStore((state) => state.value);
return <div>{value}</div>;
};
```
✅ GOOD: Single store instance, declared once
```typescript
// At the top of your store file (e.g., src/store/useCounterStore.ts)
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.
```typescript
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
© sanjeed5 · CC0-1.0 · sanjeed5/awesome-cursor-rules-mdc