| name | redux |
| description | [Applies to: **/*.{js,jsx}] This guide defines the definitive best practices for using Redux in our projects, mandating Redux Toolkit (RTK) for all state management logic to ensure consistency, maintainability, and optimal performance. |
| source | cursor_mdc |
redux Best Practices
Redux is our standard for predictable state management. All new Redux code MUST use Redux Toolkit (RTK). RTK simplifies Redux, enforces best practices, and eliminates boilerplate. Do not use vanilla Redux APIs directly.
1. Code Organization and Structure
Organize your Redux logic by feature, using a "slice" approach. This co-locates all related state, actions, and reducers, improving discoverability and maintainability.
1.1. Feature-First Directory Structure
Group all Redux-related files for a specific feature within a single directory.
src/
├── app/
│ ├── store.js // Root store configuration
│ └── hooks.js // Pre-typed Redux hooks (for TS)
└── features/
├── users/
│ ├── usersSlice.js // User slice (reducer, actions)
│ └── UserList.jsx // Component using user slice
└── posts/
├── postsSlice.js // Post slice
└── PostEditor.jsx // Component using post slice
1.2. Single-File Slices
Define your reducer, initial state, and action creators in a single file using createSlice. This is the "ducks" pattern, simplified by RTK.
❌ BAD: Scattered Redux files
export const addPost = (payload) => ({ type: 'posts/addPost', payload });
const postsReducer = (state = [], action) => { };
const ADD_POST = 'posts/addPost';
✅ GOOD: Co-located slice with createSlice
import { createSlice, nanoid } from '@reduxjs/toolkit';
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload);
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId,
date: new Date().toISOString(),
},
};
},
},
},
});
export const { postAdded } = postsSlice.actions;
export default postsSlice.reducer;
1.3. Root Store Configuration
Centralize your store setup in src/app/store.js using configureStore. This automatically includes redux-thunk, Redux DevTools integration, and development-time checks for common mistakes.
import { configureStore } from '@reduxjs/toolkit';
import usersReducer from '../features/users/usersSlice';
import postsReducer from '../features/posts/postsSlice';
export const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
},
});
2. State Organization
Keep your state as flat and normalized as possible, especially for collections of items.
2.1. Normalize Data for Collections
Store collections of items (e.g., users, posts) in a normalized way: an object mapping IDs to item objects, plus an array of IDs. This prevents data duplication and simplifies updates.
❌ BAD: Array of objects (hard to update)
✅ GOOD: Normalized state (easy to update)
Use RTK's createEntityAdapter to simplify managing normalized state.
3. Side Effects
Handle all side effects (async logic, API calls) outside of reducers.
3.1. Use RTK Query for Data Fetching
RTK Query is the preferred solution for data fetching and caching. It drastically reduces boilerplate for API interactions.
❌ BAD: Manual createAsyncThunk for every API call
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios';
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await axios.get('/api/posts');
return response.data;
});
✅ GOOD: RTK Query for data fetching
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
}),
addPost: builder.mutation({
query: (newPost) => ({
url: 'posts',
method: 'POST',
body: newPost,
}),
}),
}),
});
export const { useGetPostsQuery, useAddPostMutation } = postsApi;
3.2. Use createAsyncThunk for Complex Async Logic
For async logic that isn't simple data fetching (e.g., complex multi-step processes, interacting with non-API services), use createAsyncThunk.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const markAllNotificationsRead = createAsyncThunk(
'notifications/markAllRead',
async (_, { getState }) => {
const { notifications } = getState();
await someExternalService.updateMany(notifications.map(n => n.id));
return notifications.map(n => n.id);
}
);
4. Common Pitfalls and Gotchas
Avoid these common mistakes to prevent bugs and ensure predictable state.
4.1. Never Mutate State Outside of Reducers (or Immer)
Directly modifying the Redux state is the #1 cause of bugs. createSlice uses Immer, allowing "mutative" syntax safely within reducers.
❌ BAD: Mutating state directly
dispatch(someAction());
store.getState().posts[0].title = 'New Title';
❌ BAD: Mutating state in a vanilla reducer
const postsReducer = (state = [], action) => {
if (action.type === 'posts/update') {
state[0].title = action.payload.title;
return state;
}
return state;
};
✅ GOOD: Safe immutable updates with createSlice (Immer)
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
postUpdated(state, action) {
const { id, title } = action.payload;
const existingPost = state.find(post => post.id === id);
if (existingPost) {
existingPost.title = title;
}
},
},
});
4.2. Reducers Must Be Pure
Reducers must be pure functions: given the same inputs (state, action), they must always produce the same output, without side effects.
❌ BAD: Reducer with side effects
const postsReducer = (state = [], action) => {
if (action.type === 'posts/add') {
console.log('Adding post!');
return [...state, { ...action.payload, timestamp: Date.now() }];
}
return state;
};
✅ GOOD: Pure reducer
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload);
},
prepare(title, content) {
return {
payload: {
id: nanoid(),
title,
content,
date: new Date().toISOString(),
},
};
},
},
},
});
4.3. Avoid Non-Serializable Values in State or Actions
Do not store Promises, Symbols, Maps/Sets, functions, or class instances in your Redux state or dispatched actions. This breaks DevTools and state rehydration.
❌ BAD: Non-serializable value in state
✅ GOOD: Serializable values only
Exception: Non-serializable values are allowed in actions if a middleware (like redux-thunk) intercepts and consumes them before they reach the reducers.
5. Performance Considerations
Optimize your components to re-render only when necessary.
5.1. Use Memoized Selectors
Use createSelector (from RTK or Reselect) to create memoized selectors. These re-compute derived data only when their inputs change, preventing unnecessary component re-renders.
❌ BAD: Inline selector in useSelector (can cause unnecessary re-renders)
const activeUsers = useSelector(state =>
state.users.filter(user => user.isActive)
);
✅ GOOD: Memoized selector
import { createSelector } from '@reduxjs/toolkit';
const selectUsers = state => state.users;
export const selectActiveUsers = createSelector(
[selectUsers],
users => users.filter(user => user.isActive)
);
const activeUsers = useSelector(selectActiveUsers);
5.2. Select Minimal Data
Components should select the smallest possible amount of data they need from the store. This reduces the chance of unnecessary re-renders.
❌ BAD: Selecting entire slice
const user = useSelector(state => state.users);
✅ GOOD: Selecting specific data
const userName = useSelector(state => state.users.currentUser.name);
6. Hooks Best Practices
Always use the pre-typed useAppSelector and useAppDispatch hooks for type safety and consistency.
6.1. Pre-Typed Hooks
Define and export pre-typed versions of useSelector and useDispatch in src/app/hooks.js. This provides compile-time type safety across your application.
import { useDispatch, useSelector } from 'react-redux';
export const useAppDispatch = useDispatch;
export const useAppSelector = useSelector;
6.2. Dispatch Actions Correctly
Use useAppDispatch to get the dispatch function and call your RTK-generated action creators.
import { useAppDispatch } from '@/app/hooks';
import { postAdded } from '@/features/posts/postsSlice';
const AddPostForm = () => {
const dispatch = useAppDispatch();
const handleSubmit = () => {
dispatch(postAdded('My Title', 'My Content', 'user123'));
};
};
7. Debugging
Leverage RTK's built-in debugging capabilities.
7.1. Redux DevTools
configureStore automatically sets up Redux DevTools integration. Ensure you have the browser extension installed.
7.2. Immutable State Invariant Middleware
In development, configureStore includes middleware that checks for accidental state mutations. Pay attention to console warnings. If you see them, fix the mutation immediately.