What is Redux Toolkit?
Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. It's designed to solve common Redux pain points and establish best practices.
🧰 The Power Tools Analogy
Think of Redux Toolkit as power tools vs hand tools:
- Traditional Redux: Like using hand tools - more control but more work
- Redux Toolkit: Like using power tools - faster, safer, with built-in best practices
- configureStore: A pre-configured workshop with all tools ready
- createSlice: An all-in-one tool that combines multiple functions
- createAsyncThunk: Automated machinery for complex operations
Just as power tools make construction faster and more consistent, Redux Toolkit makes Redux development more efficient and less error-prone.
Why Redux Toolkit?
graph TD
A[Traditional Redux Problems] --> B[Boilerplate Code]
A --> C[Complex Configuration]
A --> D[Immutable Updates]
A --> E[Side Effects]
F[Redux Toolkit Solutions] --> G[Less Code]
F --> H[Pre-configured]
F --> I[Immer Integration]
F --> J[Built-in Async]
B --> G
C --> H
D --> I
E --> J
style A fill:#f96
style F fill:#9f9
Problems Redux Toolkit Solves
Before (Traditional Redux)
// Action Types
const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
// Action Creators
const fetchUsersRequest = () => ({
type: FETCH_USERS_REQUEST
});
const fetchUsersSuccess = (users) => ({
type: FETCH_USERS_SUCCESS,
payload: users
});
const fetchUsersFailure = (error) => ({
type: FETCH_USERS_FAILURE,
payload: error
});
// Async Action
const fetchUsers = () => {
return async (dispatch) => {
dispatch(fetchUsersRequest());
try {
const response = await api.getUsers();
dispatch(fetchUsersSuccess(response.data));
} catch (error) {
dispatch(fetchUsersFailure(error.message));
}
};
};
// Reducer
const initialState = {
users: [],
loading: false,
error: null
};
const usersReducer = (state = initialState, action) => {
switch (action.type) {
case FETCH_USERS_REQUEST:
return {
...state,
loading: true,
error: null
};
case FETCH_USERS_SUCCESS:
return {
...state,
loading: false,
users: action.payload
};
case FETCH_USERS_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
// Store Configuration
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
const rootReducer = combineReducers({
users: usersReducer
});
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
After (Redux Toolkit)
// All in one file with Redux Toolkit
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
import api from './api';
// Async Action
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await api.getUsers();
return response.data;
}
);
// Slice (includes actions and reducer)
const usersSlice = createSlice({
name: 'users',
initialState: {
users: [],
loading: false,
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
// Store Configuration
export const store = configureStore({
reducer: {
users: usersSlice.reducer
}
});
Core APIs of Redux Toolkit
1. configureStore
import { configureStore } from '@reduxjs/toolkit';
import usersReducer from './features/users/usersSlice';
import postsReducer from './features/posts/postsSlice';
import authReducer from './features/auth/authSlice';
// Basic configuration
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
auth: authReducer
}
});
// Advanced configuration
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
auth: authReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Ignore these action types
ignoredActions: ['your/action/type'],
// Ignore these field paths in all actions
ignoredActionPaths: ['meta.arg', 'payload.timestamp'],
// Ignore these paths in the state
ignoredPaths: ['items.dates']
},
immutableCheck: { warnAfter: 128 },
thunk: {
extraArgument: {
api: apiService,
env: process.env
}
}
}).concat(customMiddleware),
devTools: process.env.NODE_ENV !== 'production',
preloadedState: {
auth: { user: null, token: localStorage.getItem('token') }
},
enhancers: [customEnhancer]
});
// What configureStore does for you:
// 1. Combines reducers
// 2. Adds redux-thunk middleware
// 3. Adds development checks (immutability, serializability)
// 4. Sets up Redux DevTools
// 5. Applies middleware and enhancers
2. createSlice
import { createSlice } from '@reduxjs/toolkit';
// Simple slice
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => {
state.value += 1; // Immer allows direct mutation
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
// Export actions
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Export reducer
export default counterSlice.reducer;
// More complex slice
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
loading: false,
error: null
},
reducers: {
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => {
return {
payload: {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString()
}
};
}
},
toggleTodo: (state, action) => {
const todo = state.items.find(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action) => {
state.items = state.items.filter(todo => todo.id !== action.payload);
},
setFilter: (state, action) => {
state.filter = action.payload;
}
},
extraReducers: (builder) => {
// Handle actions from other slices or async thunks
builder
.addCase('auth/logout', (state) => {
// Clear todos on logout
state.items = [];
});
}
});
// Selectors
export const selectAllTodos = state => state.todos.items;
export const selectTodoById = (state, todoId) =>
state.todos.items.find(todo => todo.id === todoId);
export const selectFilteredTodos = state => {
const { items, filter } = state.todos;
switch (filter) {
case 'active':
return items.filter(todo => !todo.completed);
case 'completed':
return items.filter(todo => todo.completed);
default:
return items;
}
};
3. createAsyncThunk
import { createAsyncThunk } from '@reduxjs/toolkit';
import { client } from '../../api/client';
// Basic async thunk
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId) => {
const response = await client.get(`/users/${userId}`);
return response.data;
}
);
// Async thunk with condition
export const fetchUserIfNeeded = createAsyncThunk(
'users/fetchIfNeeded',
async (userId, { getState }) => {
const { users } = getState();
if (users.entities[userId]) {
return users.entities[userId];
}
const response = await client.get(`/users/${userId}`);
return response.data;
},
{
condition: (userId, { getState }) => {
const { users } = getState();
return !users.loading && !users.entities[userId];
}
}
);
// Async thunk with error handling
export const loginUser = createAsyncThunk(
'auth/login',
async (credentials, { rejectWithValue }) => {
try {
const response = await client.post('/auth/login', credentials);
// Save token to local storage
localStorage.setItem('token', response.data.token);
return response.data;
} catch (err) {
// Return custom error message
return rejectWithValue(err.response.data.message);
}
}
);
// Async thunk with dispatch inside
export const checkoutCart = createAsyncThunk(
'cart/checkout',
async (paymentDetails, { dispatch, getState }) => {
const { cart } = getState();
// Create order
const orderResponse = await client.post('/orders', {
items: cart.items,
total: cart.total
});
// Process payment
const paymentResponse = await client.post('/payments', {
orderId: orderResponse.data.id,
...paymentDetails
});
// Clear cart on success
dispatch(clearCart());
return {
order: orderResponse.data,
payment: paymentResponse.data
};
}
);
// Using in a slice
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: {},
loading: false,
error: null,
currentUser: null
},
reducers: {
userUpdated: (state, action) => {
const { id, changes } = action.payload;
state.entities[id] = { ...state.entities[id], ...changes };
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = true;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = false;
state.entities[action.payload.id] = action.payload;
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.currentUser = action.payload.user;
})
.addCase(loginUser.rejected, (state, action) => {
state.error = action.payload; // Custom error message
});
}
});
Working with Immer
Redux Toolkit uses Immer internally, allowing you to write "mutative" code that produces immutable updates.
// Traditional Redux (without Immer)
const todosReducer = (state = initialState, action) => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, action.payload]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'UPDATE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, ...action.payload.changes }
: todo
)
};
default:
return state;
}
};
// Redux Toolkit with Immer
const todosSlice = createSlice({
name: 'todos',
initialState: {
todos: []
},
reducers: {
addTodo: (state, action) => {
state.todos.push(action.payload); // "Mutate" directly
},
toggleTodo: (state, action) => {
const todo = state.todos.find(todo => todo.id === action.payload.id);
if (todo) {
todo.completed = !todo.completed;
}
},
updateTodo: (state, action) => {
const { id, changes } = action.payload;
const todo = state.todos.find(todo => todo.id === id);
if (todo) {
Object.assign(todo, changes);
}
}
}
});
// Advanced Immer patterns
const complexSlice = createSlice({
name: 'complex',
initialState: {
users: {},
posts: [],
settings: {
theme: 'light',
notifications: true
}
},
reducers: {
// Deep updates are easy
updateUserProfile: (state, action) => {
const { userId, profileData } = action.payload;
state.users[userId].profile = profileData;
},
// Array operations
sortPosts: (state) => {
state.posts.sort((a, b) => b.date - a.date);
},
// Complex manipulations
reorganizeData: (state) => {
// Remove unpublished posts
state.posts = state.posts.filter(post => post.published);
// Update user post counts
state.posts.forEach(post => {
state.users[post.authorId].postCount++;
});
// Toggle setting
state.settings.notifications = !state.settings.notifications;
}
}
});
// Note: Don't return anything when using Immer
const incorrectReducer = (state, action) => {
state.value = action.payload;
return state; // ❌ Don't do this!
};
const correctReducer = (state, action) => {
state.value = action.payload;
// ✅ No return needed
};
Entity Adapters
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';
// Create entity adapter
const usersAdapter = createEntityAdapter({
// Optional: Specify ID field (default is 'id')
selectId: (user) => user.userId,
// Optional: Sort function
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
// Get initial state from adapter
const initialState = usersAdapter.getInitialState({
loading: false,
error: null,
currentUser: null
});
// Async thunk
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
const response = await api.getUsers();
return response.data;
});
// Create slice with entity adapter
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
userRemoved: usersAdapter.removeOne,
usersReceived: usersAdapter.setAll
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.fulfilled, (state, action) => {
usersAdapter.setAll(state, action.payload);
state.loading = false;
});
}
});
// Export entity selectors
export const {
selectById: selectUserById,
selectIds: selectUserIds,
selectEntities: selectUserEntities,
selectAll: selectAllUsers,
selectTotal: selectTotalUsers
} = usersAdapter.getSelectors(state => state.users);
// Custom selectors
export const selectCurrentUser = state =>
state.users.currentUser ? selectUserById(state, state.users.currentUser) : null;
// Using entity adapter methods
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
});
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState(),
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postsLoaded: postsAdapter.setAll,
// Bulk operations
postsAdded: postsAdapter.addMany,
postsUpdated: postsAdapter.updateMany,
postsRemoved: postsAdapter.removeMany,
// Upsert operations (add if doesn't exist, update if does)
postUpserted: postsAdapter.upsertOne,
postsUpserted: postsAdapter.upsertMany
}
});
// Usage in components
const PostsList = () => {
const posts = useSelector(selectAllPosts);
const postIds = useSelector(selectPostIds);
const loading = useSelector(state => state.posts.loading);
return (
<div>
{postIds.map(id => (
<PostItem key={id} postId={id} />
))}
</div>
);
};
const PostItem = ({ postId }) => {
const post = useSelector(state => selectPostById(state, postId));
return <div>{post.title}</div>;
};
Real-World Example: Blog Application
// store.js
import { configureStore } from '@reduxjs/toolkit';
import postsReducer from './features/posts/postsSlice';
import usersReducer from './features/users/usersSlice';
import commentsReducer from './features/comments/commentsSlice';
import authReducer from './features/auth/authSlice';
export const store = configureStore({
reducer: {
posts: postsReducer,
users: usersReducer,
comments: commentsReducer,
auth: authReducer
}
});
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { client } from '../../api/client';
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
});
const initialState = postsAdapter.getInitialState({
status: 'idle',
error: null
});
// Async thunks
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/api/posts');
return response.data;
});
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
async (initialPost) => {
const response = await client.post('/api/posts', initialPost);
return response.data;
}
);
export const updatePost = createAsyncThunk(
'posts/updatePost',
async ({ id, changes }) => {
const response = await client.patch(`/api/posts/${id}`, changes);
return response.data;
}
);
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated: postsAdapter.updateOne,
reactionAdded(state, action) {
const { postId, reaction } = action.payload;
const existingPost = state.entities[postId];
if (existingPost) {
existingPost.reactions[reaction]++;
}
}
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
postsAdapter.upsertMany(state, action.payload);
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
.addCase(updatePost.fulfilled, (state, action) => {
postsAdapter.updateOne(state, {
id: action.payload.id,
changes: action.payload
});
});
}
});
export const { postUpdated, reactionAdded } = postsSlice.actions;
export default postsSlice.reducer;
// Selectors
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
} = postsAdapter.getSelectors(state => state.posts);
export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
);
// components/PostsList.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectAllPosts, fetchPosts } from '../features/posts/postsSlice';
import { PostExcerpt } from './PostExcerpt';
export const PostsList = () => {
const dispatch = useDispatch();
const posts = useSelector(selectAllPosts);
const postStatus = useSelector(state => state.posts.status);
const error = useSelector(state => state.posts.error);
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts());
}
}, [postStatus, dispatch]);
let content;
if (postStatus === 'loading') {
content = <div>Loading...</div>;
} else if (postStatus === 'succeeded') {
content = posts.map(post => <PostExcerpt key={post.id} post={post} />);
} else if (postStatus === 'failed') {
content = <div>{error}</div>;
}
return (
<section>
<h2>Posts</h2>
{content}
</section>
);
};
// components/AddPostForm.js
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { addNewPost } from '../features/posts/postsSlice';
import { selectAllUsers } from '../features/users/usersSlice';
export const AddPostForm = () => {
const dispatch = useDispatch();
const users = useSelector(selectAllUsers);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [userId, setUserId] = useState('');
const [addRequestStatus, setAddRequestStatus] = useState('idle');
const canSave = [title, content, userId].every(Boolean) && addRequestStatus === 'idle';
const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus('pending');
await dispatch(addNewPost({ title, content, user: userId })).unwrap();
setTitle('');
setContent('');
setUserId('');
} catch (err) {
console.error('Failed to save the post: ', err);
} finally {
setAddRequestStatus('idle');
}
}
};
return (
<section>
<h2>Add a New Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={e => setUserId(e.target.value)}>
<option value=""></option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={e => setContent(e.target.value)}
/>
<button type="button" onClick={onSavePostClicked} disabled={!canSave}>
Save Post
</button>
</form>
</section>
);
};
Migration from Traditional Redux
// Step 1: Install Redux Toolkit
// npm install @reduxjs/toolkit
// Step 2: Replace store configuration
// Before
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer
});
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
// After
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer
}
});
// Step 3: Convert reducers to slices
// Before
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { id: Date.now(), text, completed: false }
});
export const toggleTodo = (id) => ({
type: TOGGLE_TODO,
payload: id
});
const todosReducer = (state = [], action) => {
switch (action.type) {
case ADD_TODO:
return [...state, action.payload];
case TOGGLE_TODO:
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
default:
return state;
}
};
// After
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: {
reducer: (state, action) => {
state.push(action.payload);
},
prepare: (text) => ({
payload: {
id: Date.now(),
text,
completed: false
}
})
},
toggleTodo: (state, action) => {
const todo = state.find(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
// Step 4: Convert async actions to createAsyncThunk
// Before
const fetchTodosRequest = () => ({ type: 'FETCH_TODOS_REQUEST' });
const fetchTodosSuccess = (todos) => ({ type: 'FETCH_TODOS_SUCCESS', payload: todos });
const fetchTodosFailure = (error) => ({ type: 'FETCH_TODOS_FAILURE', payload: error });
export const fetchTodos = () => async (dispatch) => {
dispatch(fetchTodosRequest());
try {
const response = await api.getTodos();
dispatch(fetchTodosSuccess(response.data));
} catch (error) {
dispatch(fetchTodosFailure(error.message));
}
};
// After
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async () => {
const response = await api.getTodos();
return response.data;
}
);
// In the slice
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.todos = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
}
Best Practices
Do's
- Use createSlice for all reducers
- Keep slices focused on a single feature
- Use entity adapters for normalized data
- Write selectors in the slice file
- Use createAsyncThunk for async operations
- Take advantage of Immer for immutable updates
Don'ts
- Don't mutate state outside of createSlice
- Don't put business logic in components
- Don't create circular dependencies between slices
- Don't ignore TypeScript types (if using TS)
- Don't put all state in Redux (local state is fine)
Folder Structure
src/
app/
store.js
features/
users/
usersSlice.js
Users.js
UserDetail.js
posts/
postsSlice.js
PostsList.js
AddPostForm.js
comments/
commentsSlice.js
CommentsList.js
components/
Header.js
Footer.js
api/
client.js
App.js
index.js
Practice Exercise
Task: Create a Todo App with Redux Toolkit
Build a todo application using Redux Toolkit that includes:
- Todo CRUD operations
- Filter todos (all, active, completed)
- Async operations (fetch, save)
- Loading states and error handling
- Using entity adapter for todos
// TODO: Create the todo slice
import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
// TODO: Create entity adapter
const todosAdapter = // ...
// TODO: Create async thunks
export const fetchTodos = createAsyncThunk(/* ... */);
export const saveTodo = createAsyncThunk(/* ... */);
// TODO: Create the slice
const todosSlice = createSlice({
name: 'todos',
initialState: // ...,
reducers: {
// TODO: Add reducers
},
extraReducers: (builder) => {
// TODO: Handle async actions
}
});
// TODO: Export actions and selectors
// TODO: Create the store
import { configureStore } from '@reduxjs/toolkit';
export const store = configureStore({
reducer: {
// TODO: Add reducers
}
});
// TODO: Create components using the Redux store