Master Redux Toolkit - the modern, efficient way to write Redux logic with minimal boilerplate.
Master Redux Toolkit - the modern, efficient way to write Redux logic with minimal boilerplate.
Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. It was created to address three main concerns about Redux: configuration complexity, boilerplate code, and accidental mutations.
1// ❌ Classic Redux - Lots of boilerplate
2const INCREMENT = 'counter/increment';
3const DECREMENT = 'counter/decrement';
4
5function increment() {
6 return { type: INCREMENT };
7}
8
9function decrement() {
10 return { type: DECREMENT };
11}
12
13function counterReducer(state = { value: 0 }, action) {
14 switch (action.type) {
15 case INCREMENT:
16 return { ...state, value: state.value + 1 };
17 case DECREMENT:
18 return { ...state, value: state.value - 1 };
19 default:
20 return state;
21 }
22}1// ✅ Redux Toolkit - Minimal boilerplate
2import { createSlice } from '@reduxjs/toolkit';
3
4const counterSlice = createSlice({
5 name: 'counter',
6 initialState: { value: 0 },
7 reducers: {
8 increment: (state) => {
9 state.value += 1; // Immer handles immutability!
10 },
11 decrement: (state) => {
12 state.value -= 1;
13 }
14 }
15});
16
17export const { increment, decrement } = counterSlice.actions;
18export default counterSlice.reducer;RTK includes sensible defaults and follows Redux best practices out of the box.
Reduces boilerplate while maintaining Redux's predictability.
Includes tools for common Redux patterns like async actions and entity management.
Excellent TypeScript support and debugging capabilities.
The configureStore function wraps the original Redux createStore, providing good defaults and simplifying setup.
1// store/index.js
2import { configureStore } from '@reduxjs/toolkit';
3import counterReducer from './counterSlice';
4import userReducer from './userSlice';
5import postsReducer from './postsSlice';
6
7export const store = configureStore({
8 reducer: {
9 counter: counterReducer,
10 user: userReducer,
11 posts: postsReducer
12 }
13});
14
15export type RootState = ReturnType<typeof store.getState>;
16export type AppDispatch = typeof store.dispatch;1import { configureStore } from '@reduxjs/toolkit';
2import { persistStore, persistReducer } from 'redux-persist';
3import storage from 'redux-persist/lib/storage';
4
5// Custom middleware
6const loggerMiddleware = (store) => (next) => (action) => {
7 console.log('Dispatching:', action);
8 const result = next(action);
9 console.log('Next state:', store.getState());
10 return result;
11};
12
13const persistConfig = {
14 key: 'root',
15 storage,
16 whitelist: ['user'] // Only persist user state
17};
18
19const persistedReducer = persistReducer(persistConfig, rootReducer);
20
21export const store = configureStore({
22 reducer: persistedReducer,
23 middleware: (getDefaultMiddleware) =>
24 getDefaultMiddleware({
25 serializableCheck: {
26 ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
27 },
28 }).concat(loggerMiddleware),
29 devTools: process.env.NODE_ENV !== 'production',
30 preloadedState: {
31 counter: { value: 10 } // Initial state
32 }
33});Automatically enabled in development with no additional setup.
Better error handling and debugging information.
1const store = configureStore({
2 reducer: rootReducer,
3 middleware: (getDefaultMiddleware) => {
4 const middleware = getDefaultMiddleware({
5 // Customize serializable check
6 serializableCheck: {
7 ignoredActions: ['persist/PERSIST'],
8 ignoredPaths: ['register']
9 }
10 });
11
12 // Add middleware only in development
13 if (process.env.NODE_ENV === 'development') {
14 const { createLogger } = require('redux-logger');
15 middleware.push(createLogger({
16 collapsed: true,
17 diff: true
18 }));
19 }
20
21 return middleware;
22 },
23 devTools: {
24 name: 'MyApp Redux Store',
25 trace: true,
26 traceLimit: 25
27 }
28});1import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
2
3export type RootState = ReturnType<typeof store.getState>;
4export type AppDispatch = typeof store.dispatch;
5
6// Typed hooks
7export const useAppDispatch = () => useDispatch<AppDispatch>();
8export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;A "slice" is a collection of Redux reducer logic and actions for a single feature of your app. The createSlice function lets you write reducers that use Immer internally to enable "mutative" logic.
1import { createSlice } from '@reduxjs/toolkit';
2
3const todoSlice = createSlice({
4 name: 'todos', // String identifier for the slice
5 initialState: {
6 items: [],
7 filter: 'all', // 'all', 'active', 'completed'
8 loading: false
9 },
10 reducers: {
11 // Reducer functions (automatically generate actions)
12 addTodo: (state, action) => {
13 state.items.push({
14 id: Date.now(),
15 text: action.payload,
16 completed: false,
17 createdAt: new Date().toISOString()
18 });
19 },
20
21 toggleTodo: (state, action) => {
22 const todo = state.items.find(item => item.id === action.payload);
23 if (todo) {
24 todo.completed = !todo.completed;
25 }
26 },
27
28 removeTodo: (state, action) => {
29 state.items = state.items.filter(item => item.id !== action.payload);
30 },
31
32 setFilter: (state, action) => {
33 state.filter = action.payload;
34 },
35
36 clearCompleted: (state) => {
37 state.items = state.items.filter(item => !item.completed);
38 }
39 }
40});
41
42// Export actions (automatically generated)
43export const { addTodo, toggleTodo, removeTodo, setFilter, clearCompleted } = todoSlice.actions;
44
45// Export reducer
46export default todoSlice.reducer;1const postsSlice = createSlice({
2 name: 'posts',
3 initialState: {
4 posts: [],
5 status: 'idle'
6 },
7 reducers: {
8 postAdded: {
9 reducer: (state, action) => {
10 state.posts.push(action.payload);
11 },
12 prepare: (title, content, userId) => {
13 return {
14 payload: {
15 id: nanoid(),
16 title,
17 content,
18 userId,
19 date: new Date().toISOString(),
20 reactions: { thumbsUp: 0, hooray: 0, heart: 0, rocket: 0, eyes: 0 }
21 }
22 };
23 }
24 },
25
26 reactionAdded: {
27 reducer: (state, action) => {
28 const { postId, reaction } = action.payload;
29 const existingPost = state.posts.find(post => post.id === postId);
30 if (existingPost) {
31 existingPost.reactions[reaction]++;
32 }
33 },
34 prepare: (postId, reaction) => ({
35 payload: { postId, reaction }
36 })
37 }
38 }
39});
40
41// Usage with custom prepare:
42dispatch(postAdded('My Post Title', 'Post content here', user.id));
43dispatch(reactionAdded(post.id, 'thumbsUp'));1const userSlice = createSlice({
2 name: 'user',
3 initialState: {
4 profile: {
5 personal: {
6 name: '',
7 email: '',
8 avatar: null
9 },
10 preferences: {
11 theme: 'light',
12 notifications: {
13 email: true,
14 push: false,
15 sms: false
16 },
17 privacy: {
18 profileVisible: true,
19 showEmail: false
20 }
21 }
22 },
23 settings: {}
24 },
25 reducers: {
26 updateProfile: (state, action) => {
27 // Direct "mutation" with Immer
28 Object.assign(state.profile.personal, action.payload);
29 },
30
31 toggleNotification: (state, action) => {
32 const { type } = action.payload;
33 state.profile.preferences.notifications[type] =
34 !state.profile.preferences.notifications[type];
35 },
36
37 updatePrivacySetting: (state, action) => {
38 const { setting, value } = action.payload;
39 state.profile.preferences.privacy[setting] = value;
40 },
41
42 resetPreferences: (state) => {
43 state.profile.preferences = {
44 theme: 'light',
45 notifications: {
46 email: true,
47 push: false,
48 sms: false
49 },
50 privacy: {
51 profileVisible: true,
52 showEmail: false
53 }
54 };
55 }
56 }
57});1// In your slice file
2export const selectTodos = (state) => state.todos.items;
3export const selectTodoFilter = (state) => state.todos.filter;
4
5// Memoized selectors with Reselect (install @reduxjs/reselect)
6import { createSelector } from '@reduxjs/reselect';
7
8export const selectFilteredTodos = createSelector(
9 [selectTodos, selectTodoFilter],
10 (todos, filter) => {
11 switch (filter) {
12 case 'active':
13 return todos.filter(todo => !todo.completed);
14 case 'completed':
15 return todos.filter(todo => todo.completed);
16 default:
17 return todos;
18 }
19 }
20);
21
22export const selectTodoStats = createSelector(
23 [selectTodos],
24 (todos) => ({
25 total: todos.length,
26 completed: todos.filter(todo => todo.completed).length,
27 active: todos.filter(todo => !todo.completed).length
28 })
29);Each slice should handle one specific domain of your application state.
Follow naming conventions for actions and reducers.
Write "mutative" logic - Immer handles immutability.
Export selectors alongside your slice for accessing state.
Keep actions, reducers, and selectors together.
RTK comes with a powerful middleware system that includes essential development tools and performance optimizations.
1// What RTK includes by default:
2const store = configureStore({
3 reducer: rootReducer,
4 // Default middleware (no need to add manually):
5 // - redux-thunk (for async actions)
6 // - immutability check (development only)
7 // - serializable check (development only)
8});1import { configureStore } from '@reduxjs/toolkit';
2import { createLogger } from 'redux-logger';
3
4const store = configureStore({
5 reducer: rootReducer,
6 middleware: (getDefaultMiddleware) =>
7 getDefaultMiddleware({
8 // Customize built-in middleware
9 thunk: {
10 extraArgument: { api: myApiService }
11 },
12 immutableCheck: {
13 ignoredPaths: ['ignoredField', 'deep.nested.field']
14 },
15 serializableCheck: {
16 ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
17 ignoredPaths: ['register', 'rehydrate']
18 }
19 })
20 // Add custom middleware
21 .concat(
22 process.env.NODE_ENV === 'development' ? createLogger({
23 collapsed: true,
24 duration: true,
25 diff: true
26 }) : []
27 )
28});1// Custom performance monitoring
2const performanceMiddleware = (store) => (next) => (action) => {
3 const start = performance.now();
4 const result = next(action);
5 const end = performance.now();
6
7 if (end - start > 16) { // > 16ms (one frame)
8 console.warn(`Slow action: ${action.type} took ${end - start}ms`);
9 }
10
11 return result;
12};
13
14// Analytics middleware
15const analyticsMiddleware = (store) => (next) => (action) => {
16 // Track user actions
17 if (action.type.startsWith('user/')) {
18 analytics.track(action.type, {
19 payload: action.payload,
20 timestamp: Date.now()
21 });
22 }
23
24 return next(action);
25};RTK automatically configures Redux DevTools with enhanced features.
1const store = configureStore({
2 reducer: rootReducer,
3 devTools: process.env.NODE_ENV !== 'production'
4});1const store = configureStore({
2 reducer: rootReducer,
3 devTools: {
4 name: 'My App Redux Store',
5 trace: true, // Enable stack trace
6 traceLimit: 25, // Limit trace depth
7 actionCreators: {
8 // Add action creators for easier debugging
9 ...counterSlice.actions,
10 ...userSlice.actions
11 },
12 features: {
13 pause: true, // Pause recording
14 lock: true, // Lock recording
15 persist: true, // Persist states on page reload
16 export: true, // Export states
17 import: 'custom', // Import states
18 jump: true, // Jump to state
19 skip: true, // Skip actions
20 reorder: true, // Reorder actions
21 dispatch: true, // Dispatch custom actions
22 test: true // Generate tests
23 }
24 }
25});The DevTools enable powerful debugging features:
1const userSlice = createSlice({
2 name: 'user',
3 initialState: { /* ... */ },
4 reducers: {
5 // ✅ Descriptive action names
6 profileUpdateRequested: (state) => {
7 state.loading = true;
8 },
9 profileUpdateSucceeded: (state, action) => {
10 state.loading = false;
11 state.profile = action.payload;
12 },
13 profileUpdateFailed: (state, action) => {
14 state.loading = false;
15 state.error = action.payload;
16 }
17 }
18});1// ✅ Well-structured state for DevTools
2const initialState = {
3 // Group related data
4 entities: {
5 users: {},
6 posts: {},
7 comments: {}
8 },
9
10 // Separate UI state
11 ui: {
12 selectedUserId: null,
13 filter: 'all',
14 modal: {
15 isOpen: false,
16 type: null,
17 data: null
18 }
19 },
20
21 // Loading and error states
22 status: {
23 users: { loading: false, error: null },
24 posts: { loading: false, error: null }
25 }
26};1// Add debugging info to actions
2const debugSlice = createSlice({
3 name: 'debug',
4 initialState: { logs: [] },
5 reducers: {
6 logAction: (state, action) => {
7 if (process.env.NODE_ENV === 'development') {
8 state.logs.push({
9 action: action.type,
10 payload: action.payload,
11 timestamp: Date.now(),
12 stackTrace: new Error().stack
13 });
14 }
15 }
16 }
17});1const stateValidationMiddleware = (store) => (next) => (action) => {
2 const result = next(action);
3 const state = store.getState();
4
5 // Validate critical state invariants
6 if (state.user.profile && !state.user.profile.id) {
7 console.error('Invalid state: User profile missing ID');
8 }
9
10 if (state.posts.items.length > 1000) {
11 console.warn('Performance warning: Too many posts in memory');
12 }
13
14 return result;
15};1src/
2├── store/
3│ ├── index.ts # Store configuration
4│ └── middleware/ # Custom middleware
5├── features/
6│ ├── auth/
7│ │ ├── authSlice.ts # Auth state slice
8│ │ ├── authAPI.ts # Auth API calls
9│ │ └── index.ts # Public exports
10│ ├── posts/
11│ │ ├── postsSlice.ts
12│ │ ├── postsAPI.ts
13│ │ └── index.ts
14│ └── common/
15│ ├── commonSlice.ts # Shared state
16│ └── selectors.ts # Reusable selectors1// features/posts/postsSlice.ts
2import { createSlice, createSelector } from '@reduxjs/toolkit';
3import type { RootState } from '../../store';
4
5// Types
6interface Post {
7 id: string;
8 title: string;
9 content: string;
10 author: string;
11 createdAt: string;
12 tags: string[];
13}
14
15interface PostsState {
16 entities: Record<string, Post>;
17 ids: string[];
18 status: 'idle' | 'loading' | 'succeeded' | 'failed';
19 error: string | null;
20 filters: {
21 searchTerm: string;
22 selectedTags: string[];
23 sortBy: 'date' | 'title' | 'author';
24 };
25}
26
27// Initial state
28const initialState: PostsState = {
29 entities: {},
30 ids: [],
31 status: 'idle',
32 error: null,
33 filters: {
34 searchTerm: '',
35 selectedTags: [],
36 sortBy: 'date'
37 }
38};
39
40// Slice
41const postsSlice = createSlice({
42 name: 'posts',
43 initialState,
44 reducers: {
45 postsLoading: (state) => {
46 state.status = 'loading';
47 },
48 postsReceived: (state, action) => {
49 state.status = 'succeeded';
50 // Normalize data
51 action.payload.forEach(post => {
52 state.entities[post.id] = post;
53 if (!state.ids.includes(post.id)) {
54 state.ids.push(post.id);
55 }
56 });
57 },
58 postsError: (state, action) => {
59 state.status = 'failed';
60 state.error = action.payload;
61 },
62 postUpdated: (state, action) => {
63 const { id, changes } = action.payload;
64 if (state.entities[id]) {
65 Object.assign(state.entities[id], changes);
66 }
67 },
68 filterUpdated: (state, action) => {
69 Object.assign(state.filters, action.payload);
70 }
71 }
72});
73
74// Actions
75export const {
76 postsLoading,
77 postsReceived,
78 postsError,
79 postUpdated,
80 filterUpdated
81} = postsSlice.actions;
82
83// Selectors
84export const selectPostsState = (state: RootState) => state.posts;
85export const selectAllPosts = (state: RootState) =>
86 state.posts.ids.map(id => state.posts.entities[id]);
87
88export const selectPostById = (state: RootState, postId: string) =>
89 state.posts.entities[postId];
90
91// Memoized selectors
92export const selectFilteredPosts = createSelector(
93 [selectAllPosts, (state: RootState) => state.posts.filters],
94 (posts, filters) => {
95 return posts
96 .filter(post =>
97 post.title.toLowerCase().includes(filters.searchTerm.toLowerCase()) ||
98 post.content.toLowerCase().includes(filters.searchTerm.toLowerCase())
99 )
100 .filter(post =>
101 filters.selectedTags.length === 0 ||
102 filters.selectedTags.some(tag => post.tags.includes(tag))
103 )
104 .sort((a, b) => {
105 switch (filters.sortBy) {
106 case 'title':
107 return a.title.localeCompare(b.title);
108 case 'author':
109 return a.author.localeCompare(b.author);
110 case 'date':
111 default:
112 return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
113 }
114 });
115 }
116);
117
118export const selectPostsStats = createSelector(
119 [selectAllPosts],
120 (posts) => ({
121 total: posts.length,
122 byAuthor: posts.reduce((acc, post) => {
123 acc[post.author] = (acc[post.author] || 0) + 1;
124 return acc;
125 }, {} as Record<string, number>),
126 allTags: [...new Set(posts.flatMap(post => post.tags))]
127 })
128);
129
130// Reducer
131export default postsSlice.reducer;1// ✅ Normalized state for better performance
2interface NormalizedState<T> {
3 entities: Record<string, T>;
4 ids: string[];
5}
6
7// Use with RTK's createEntityAdapter (covered in advanced lessons)
8import { createEntityAdapter } from '@reduxjs/toolkit';
9
10const postsAdapter = createEntityAdapter<Post>({
11 sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt)
12});
13
14const initialState = postsAdapter.getInitialState({
15 status: 'idle',
16 error: null
17});1// ✅ Update only what's necessary
2const userSlice = createSlice({
3 name: 'user',
4 initialState: {
5 profile: { id: '', name: '', email: '' },
6 preferences: { theme: 'light', language: 'en' },
7 settings: { notifications: true, privacy: 'public' }
8 },
9 reducers: {
10 // Update only profile fields
11 profileUpdated: (state, action) => {
12 Object.assign(state.profile, action.payload);
13 },
14
15 // Update single preference
16 preferenceUpdated: (state, action) => {
17 const { key, value } = action.payload;
18 state.preferences[key] = value;
19 },
20
21 // Batch preference updates
22 preferencesUpdated: (state, action) => {
23 Object.assign(state.preferences, action.payload);
24 }
25 }
26});1// Common error slice
2const errorSlice = createSlice({
3 name: 'errors',
4 initialState: {
5 global: null,
6 field: {} as Record<string, string>
7 },
8 reducers: {
9 errorSet: (state, action) => {
10 const { type, message, field } = action.payload;
11 if (field) {
12 state.field[field] = message;
13 } else {
14 state.global = message;
15 }
16 },
17 errorCleared: (state, action) => {
18 const { field } = action.payload || {};
19 if (field) {
20 delete state.field[field];
21 } else {
22 state.global = null;
23 state.field = {};
24 }
25 }
26 }
27});
28
29// Usage in other slices
30const postsSlice = createSlice({
31 name: 'posts',
32 initialState: { /* ... */ },
33 reducers: {
34 postCreateFailed: (state, action) => {
35 // Handle local error state
36 state.error = action.payload;
37 }
38 }
39});
40
41// Middleware for global error handling
42const errorHandlingMiddleware = (store) => (next) => (action) => {
43 if (action.type.endsWith('/rejected')) {
44 store.dispatch(errorSet({
45 type: 'api',
46 message: action.payload?.message || 'An error occurred'
47 }));
48 }
49 return next(action);
50};1// postsSlice.test.ts
2import postsReducer, {
3 postsLoading,
4 postsReceived,
5 postUpdated
6} from './postsSlice';
7
8describe('postsSlice', () => {
9 const initialState = {
10 entities: {},
11 ids: [],
12 status: 'idle',
13 error: null,
14 filters: {
15 searchTerm: '',
16 selectedTags: [],
17 sortBy: 'date' as const
18 }
19 };
20
21 it('should handle postsLoading', () => {
22 const action = postsLoading();
23 const state = postsReducer(initialState, action);
24
25 expect(state.status).toBe('loading');
26 });
27
28 it('should handle postsReceived', () => {
29 const posts = [
30 { id: '1', title: 'Test Post', content: 'Content', author: 'User', createdAt: '2023-01-01', tags: [] }
31 ];
32
33 const action = postsReceived(posts);
34 const state = postsReducer(initialState, action);
35
36 expect(state.status).toBe('succeeded');
37 expect(state.entities['1']).toEqual(posts[0]);
38 expect(state.ids).toContain('1');
39 });
40
41 it('should handle postUpdated', () => {
42 const existingState = {
43 ...initialState,
44 entities: { '1': { id: '1', title: 'Old Title', content: 'Content', author: 'User', createdAt: '2023-01-01', tags: [] } },
45 ids: ['1']
46 };
47
48 const action = postUpdated({ id: '1', changes: { title: 'New Title' } });
49 const state = postsReducer(existingState, action);
50
51 expect(state.entities['1'].title).toBe('New Title');
52 expect(state.entities['1'].content).toBe('Content'); // Unchanged
53 });
54});1import { selectFilteredPosts, selectPostsStats } from './postsSlice';
2
3describe('posts selectors', () => {
4 const mockState = {
5 posts: {
6 entities: {
7 '1': { id: '1', title: 'React Post', content: 'About React', author: 'John', createdAt: '2023-01-01', tags: ['react'] },
8 '2': { id: '2', title: 'Vue Post', content: 'About Vue', author: 'Jane', createdAt: '2023-01-02', tags: ['vue'] }
9 },
10 ids: ['1', '2'],
11 status: 'succeeded',
12 error: null,
13 filters: { searchTerm: 'React', selectedTags: [], sortBy: 'date' as const }
14 }
15 };
16
17 it('should filter posts by search term', () => {
18 const filtered = selectFilteredPosts(mockState);
19 expect(filtered).toHaveLength(1);
20 expect(filtered[0].title).toBe('React Post');
21 });
22
23 it('should calculate posts stats', () => {
24 const stats = selectPostsStats(mockState);
25 expect(stats.total).toBe(2);
26 expect(stats.byAuthor.John).toBe(1);
27 expect(stats.allTags).toContain('react');
28 });
29});1// 1. Start by configuring store with RTK
2const store = configureStore({
3 reducer: {
4 // Keep existing reducers
5 oldCounter: classicCounterReducer,
6 // Add new RTK slices
7 newFeature: newFeatureSlice.reducer
8 }
9});
10
11// 2. Gradually convert slices
12// Before (classic Redux)
13const ADD_TODO = 'todos/add';
14const addTodo = (text) => ({ type: ADD_TODO, payload: text });
15
16// After (RTK)
17const todosSlice = createSlice({
18 name: 'todos',
19 initialState: [],
20 reducers: {
21 addTodo: (state, action) => {
22 state.push({ id: nanoid(), text: action.payload, completed: false });
23 }
24 }
25});These patterns and practices ensure your Redux Toolkit implementation is maintainable, performant, and follows industry best practices.
Your progress is saved in this browser, on this device only.