Opinionated rules for designing, implementing and maintaining robust, scalable, and highly-performant state management in React applications.
Stop wrestling with complex state architectures and start building React applications that actually scale. These production-ready rules eliminate the guesswork from state management decisions, giving you a clear path from local component state to enterprise-scale data flows.
You know the drill. Your React app starts simple—a few useState hooks scattered across components. Then business requirements evolve. Suddenly you're prop-drilling authentication state through six components, duplicating server data between Redux and React Query, and debugging why your shopping cart re-renders every keystroke in the search bar.
The real frustration? Most state management advice treats every problem like it needs a global solution. The result? Codebases littered with over-engineered Context providers, Redux slices for simple UI toggles, and performance bottlenecks that shouldn't exist.
These Cursor Rules provide a decision framework for React state management that scales with your application complexity. Instead of defaulting to one solution for everything, you get clear guidelines for choosing the right tool at the right time—from local useState to Redux Toolkit to atomic state patterns.
What makes this different:
Instead of debating state architecture for each feature, follow established patterns:
Built-in optimization patterns prevent common performance traps:
Consistent patterns across your entire application:
Before (typical approach):
// AuthContext.tsx - Everything in one context
const AuthContext = createContext<{
user: User | null;
isLoading: boolean;
login: (credentials: LoginData) => Promise<void>;
logout: () => void;
}>({});
// Every component using auth re-renders on any auth state change
After (with these rules):
// auth/auth.slice.ts - Redux slice for persistent auth state
export const useAuth = () => {
const dispatch = useAppDispatch();
const user = useAppSelector(selectCurrentUser);
const isAuthenticated = useAppSelector(selectIsAuthenticated);
return {
user,
isAuthenticated,
login: (credentials: LoginData) => dispatch(loginAsync(credentials)),
logout: () => dispatch(logoutUser())
};
};
// Components only re-render when their specific auth data changes
Before (data duplication):
// Cart data lives in both Redux and server
// Constant sync issues between local and remote state
const cartItems = useSelector(selectCartItems); // Redux
const { data: serverCart } = useQuery(['cart']); // React Query
// Which is the source of truth?
After (clear separation):
// Server state in React Query only
const { data: cartItems, mutate: updateCart } = useCartQuery();
// Local UI state in component
const [isExpanded, setIsExpanded] = useState(false);
// Optimistic updates with automatic rollback
const addItem = useMutation({
mutationFn: addCartItemAPI,
onMutate: async (newItem) => {
await queryClient.cancelQueries(['cart']);
const previousCart = queryClient.getQueryData(['cart']);
queryClient.setQueryData(['cart'], old => [...old, newItem]);
return { previousCart };
},
onError: (err, newItem, context) => {
queryClient.setQueryData(['cart'], context.previousCart);
}
});
Before (prop drilling nightmare):
// FormContainer.tsx
const [formData, setFormData] = useState(initialData);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Pass everything down through multiple levels
<PersonalInfo
data={formData.personal}
errors={errors.personal}
onChange={(personal) => setFormData(prev => ({...prev, personal}))}
isSubmitting={isSubmitting}
/>
After (useReducer pattern):
// form/useFormState.ts
type FormAction =
| { type: 'UPDATE_FIELD'; field: string; value: any }
| { type: 'SET_ERRORS'; errors: Record<string, string> }
| { type: 'SET_SUBMITTING'; isSubmitting: boolean };
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'UPDATE_FIELD':
return { ...state, data: { ...state.data, [action.field]: action.value } };
// ... other cases
}
};
export const useFormState = () => {
const [state, dispatch] = useReducer(formReducer, initialState);
return {
...state,
updateField: (field: string, value: any) =>
dispatch({ type: 'UPDATE_FIELD', field, value }),
};
};
Add these TypeScript configurations to enable strict type checking:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}
Install essential packages:
npm install @reduxjs/toolkit react-redux @tanstack/react-query zustand immer reselect
npm install -D @types/react @types/react-dom eslint-plugin-react-hooks
Create your folder structure:
src/
features/ # Feature-based organization
auth/
Auth.tsx # Main component
auth.slice.ts # Redux slice
useAuth.ts # Typed hooks
types.ts # TypeScript definitions
stores/ # Global stores (Zustand/Jotai)
theme.store.ts
app/
store.ts # Redux store configuration
query.ts # React Query client
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { authSlice } from '../features/auth/auth.slice';
export const store = configureStore({
reducer: {
auth: authSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: true,
immutableCheck: true,
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// features/auth/auth.slice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
export const loginAsync = createAsyncThunk(
'auth/login',
async (credentials: LoginCredentials) => {
const response = await authAPI.login(credentials);
return response.data;
}
);
const authSlice = createSlice({
name: 'auth',
initialState: {
user: null as User | null,
isLoading: false,
error: null as string | null,
},
reducers: {
logout: (state) => {
state.user = null;
},
},
extraReducers: (builder) => {
builder
.addCase(loginAsync.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(loginAsync.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload;
})
.addCase(loginAsync.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Login failed';
});
},
});
// Selectors
export const selectCurrentUser = (state: RootState) => state.auth.user;
export const selectIsAuthenticated = (state: RootState) => !!state.auth.user;
// Typed hook
export const useAuth = () => {
const dispatch = useAppDispatch();
const user = useAppSelector(selectCurrentUser);
const isAuthenticated = useAppSelector(selectIsAuthenticated);
return {
user,
isAuthenticated,
login: (credentials: LoginCredentials) => dispatch(loginAsync(credentials)),
logout: () => dispatch(authSlice.actions.logout()),
};
};
export default authSlice;
// components/ErrorBoundary.tsx
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('State management error:', error, errorInfo);
// Send to monitoring service
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
These aren't theoretical benefits—they're the measurable outcomes teams report when implementing systematic state management practices. Your React applications become more predictable, your team moves faster, and your users get better performance.
Ready to transform your React state management? These rules provide the foundation for building applications that scale without the complexity overhead.
You are an expert in React 18+, TypeScript 4+, Redux Toolkit, React Query (TanStack Query), Zustand, Jotai, Recoil, Immer, and modern build tooling (Vite/ESBuild).
Key Principles
- Local first: prefer `useState`/`useReducer` in the component that owns the concern before promoting to global.
- Single source of truth per concern; never derive two mutable copies of the same data.
- State is immutable and predictable. All updates are pure, synchronous functions (reducers or set functions) that return new objects.
- Separate state logic from UI: hooks/selectors handle data, components handle rendering.
- Collocate but isolate: keep slice/store file next to the feature yet expose only typed hooks.
- Opt-in global state: promote only cross-cutting concerns. Avoid Context for fast-changing data.
- Performance first: memoize selectors, batch updates, split providers.
- Convention over configuration: identical file naming, action naming, folder layout across codebase.
TypeScript & React
- Always write functional components; never use `class` components.
- Use strict TypeScript (`"strict": true`) and `eslint-plugin-react-hooks`.
- All state hooks are strongly typed with generics: `const [user, setUser] = useState<User | null>(null)`.
- File naming: `kebab-case` for folders, `PascalCase.tsx` for components, `*.slice.ts` for Redux slices, `*.store.ts` for Zustand/Jotai stores.
- Action / atom / selector names are `camelCase`, verbs first (`incrementCounter`, `setTheme`).
- React components > 100 LOC must be split into subcomponents or custom hooks.
Error Handling and Validation
- Wrap every page root in an Error Boundary component.
- Async thunks / query functions use `try/catch`; errors are re-thrown after adding contextual info.
- Provide typed error objects (`AppError`) with a `code` and `message` field.
- Redux listeners or Zustand middleware logs every rejected promise to Sentry.
- Prefer early returns over nested `if` for error paths.
Redux Toolkit Rules
- Use `createSlice` with `immer` for immutable updates; never mutate outside reducers.
- Keep each slice under 300 LOC and scoped to one domain (e.g., `auth`, `cart`).
- Use `createAsyncThunk` for side effects; keep async logic out of components.
- Export typed hooks from each slice file:
```ts
export const useCart = () => {
const dispatch = useAppDispatch();
const items = useAppSelector(selectCartItems);
return { items, add: (p: Product) => dispatch(addProduct(p)) };
};
```
- Use `reselect` for memoized selectors; never read state shape directly in UI.
- Enable `serializableCheck` and `immutableCheck` in `configureStore`.
React Query / TanStack Query
- Treat all remote data as *server state*; never duplicate it in Redux.
- Use descriptive query keys: `["users", userId]`.
- Set `staleTime` & `cacheTime` explicitly; default is never ok in prod.
- Always provide `onError` handler and surface user-friendly toasts.
- Use optimistic updates with `queryClient.setQueryData` followed by rollback on error.
Zustand / Jotai / Recoil Rules
- Keep store atoms small and focused; prefer many small atoms over one big.
- Zustand: use `subscribeWithSelector` middleware and export a typed hook (e.g., `useAuthStore`).
- Recoil: derive data with `selectorFamily` for parametric reads and memoization.
- Jotai: colocate atoms inside feature folder; use `immerAtom` for deep updates.
Local State Patterns
- Use `useReducer` when state is an object with >3 fields or multiple actions.
- Use `useContext` only for static configuration (theme, i18n) or rarely-changing data.
- Batch rapid successive updates with `startTransition` for non-urgent UI work.
Testing
- Unit-test reducers, atoms, and query functions: cover every branch & edge case.
- Use React Testing Library to assert UI after state changes, not internal implementation.
- Mock network with MSW; never hit real APIs in unit tests.
- Minimum coverage: reducers 100 %, hooks 90 %, components 80 %.
Performance
- Memoize components with `React.memo` when props are stable.
- Use `useMemo`/`useCallback` for expensive calculations or stable callbacks.
- Split Context providers: one provider per concern to limit re-renders.
- Enable React DevTools profiler and inspect flamegraph every release.
Security
- Never store JWT/refresh tokens in Redux or global state. Use HttpOnly cookies or `SecureStore`.
- Sanitize all server-derived data before inserting into state.
Folder Structure Example
```
src/
features/
auth/
Auth.tsx
auth.slice.ts
useAuth.ts
types.ts
cart/
Cart.tsx
cart.slice.ts
selectors.ts
stores/
theme.store.ts
hooks/
useResponsive.ts
app/
store.ts // configureStore
query.ts // React Query client
```
Common Pitfalls & Solutions
- ❌ Storing fetched lists in both Redux and Query. ✅ Keep in Query, derive UI state via selectors.
- ❌ Using Context for live counters. ✅ Use Zustand/Jotai atom with selector.
- ❌ Mutating state objects in effect callbacks. ✅ Always update through dispatched action/hook setter.
Checklist Before Merge
- [ ] No prop drilling of writable state.
- [ ] All selectors memoized.
- [ ] Error boundaries cover new component trees.
- [ ] No global state introduced without documented reason.
- [ ] Tests added/updated.