By Akshay Singh
How to Add Redux Toolkit to a React Project (Complete Guide)
As your React application grows beyond a few components, state management becomes a real challenge. You start passing props through multiple layers, lifting state up repeatedly, and losing track of which component owns what data.
Redux solves this by giving you a single, centralized store that any component can read from and write to. And Redux Toolkit (RTK) makes setting it up dramatically simpler than the old boilerplate-heavy approach.
In this guide, you'll learn how to integrate Redux Toolkit into a React project from scratch — including store setup, feature slices, async data fetching with createAsyncThunk, state persistence with Redux Persist, and production-ready patterns.
What is Redux Toolkit?
Redux Toolkit is the official, recommended way to write Redux logic. It was created by the Redux team to address three common complaints:
- Too much boilerplate — action types, action creators, reducers, switch statements
- Too many packages — redux, react-redux, redux-thunk, immer, reselect
- Too complex to configure — setting up the store, middleware, and dev tools manually
Redux Toolkit bundles all of these into a single package with opinionated defaults:
configureStore— sets up the store with good defaults (redux-thunk, dev tools)createSlice— generates action creators and reducers from a single definitioncreateAsyncThunk— handles async logic (API calls) with loading/error states- Built-in Immer — lets you write "mutating" code that's actually immutable under the hood
When to Use Redux (and When Not To)
Use Redux when:
- Multiple components need to read and update the same state
- You have complex state logic (authentication, multi-step forms, cart management)
- You need to persist state across page reloads
- You want predictable, debuggable state updates with Redux DevTools
- Your app has asynchronous workflows (API calls, WebSocket updates)
Don't use Redux when:
- Your app is small (under 10 components)
- State is mostly local to individual components
- You only need simple shared state (React Context +
useReduceris simpler) - You're fetching server data (consider React Query or SWR instead)
A good rule of thumb: if you're using
useContextwithuseReducerand it's getting painful, that's when Redux Toolkit starts making sense.
Setting Up Redux Toolkit Step by Step
1. Install Dependencies
npm install @reduxjs/toolkit react-redux
If you want state persistence (data survives page reloads):
npm install redux-persist
If you want action logging in development:
npm install redux-logger --save-dev
2. Folder Structure
A clean folder structure makes your Redux code scalable. Organize by feature, not by file type:
src/
redux/
store.js # Store configuration
rootReducer.js # Combines all slices
features/
auth/
authSlice.js # Auth state, reducers, thunks
todos/
todosSlice.js # Todos state, reducers, thunks
users/
usersSlice.js # Users state, reducers, thunks
Each feature folder contains a single slice file. As the feature grows, you can split it:
features/
auth/
authSlice.js # Reducers and actions
authThunks.js # Async thunks
authSelectors.js # Memoized selectors
3. Create a Feature Slice
A slice is a collection of reducer logic and actions for a single feature. Redux Toolkit's createSlice generates action creators automatically from your reducer functions:
// redux/features/todos/todosSlice.js
import { createSlice } from "@reduxjs/toolkit";
const todosSlice = createSlice({
name: "todos",
initialState: {
items: [],
filter: "all", // "all" | "active" | "completed"
},
reducers: {
addTodo: (state, action) => {
// Immer lets you "mutate" state directly — it handles immutability
state.items.push({
id: Date.now(),
text: action.payload,
completed: false,
createdAt: new Date().toISOString(),
});
},
toggleTodo: (state, action) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
setFilter: (state, action) => {
state.filter = action.payload;
},
clearCompleted: (state) => {
state.items = state.items.filter((t) => !t.completed);
},
},
});
// Action creators are generated automatically
export const { addTodo, toggleTodo, removeTodo, setFilter, clearCompleted } =
todosSlice.actions;
// Export the reducer to combine in the store
export default todosSlice.reducer;
Notice how you can write state.items.push(...) directly. Under the hood, Immer creates a new immutable state. You don't need spread operators or manual immutability.
4. Add Async Logic with createAsyncThunk
Most real applications need to fetch data from APIs. createAsyncThunk handles the async lifecycle (pending, fulfilled, rejected) automatically:
// redux/features/users/usersSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
// Define the async thunk
export const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: "users",
initialState: {
data: [],
loading: false,
error: null,
},
reducers: {
clearUsers: (state) => {
state.data = [];
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { clearUsers } = usersSlice.actions;
export default usersSlice.reducer;
The thunk automatically dispatches three action types:
users/fetchUsers/pending→ sets loading to trueusers/fetchUsers/fulfilled→ stores the datausers/fetchUsers/rejected→ stores the error
5. Combine Reducers
// redux/rootReducer.js
import { combineReducers } from "@reduxjs/toolkit";
import todosReducer from "./features/todos/todosSlice";
import usersReducer from "./features/users/usersSlice";
const rootReducer = combineReducers({
todos: todosReducer,
users: usersReducer,
});
export default rootReducer;
6. Configure the Store
// redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./rootReducer";
export const store = configureStore({
reducer: rootReducer,
// Redux DevTools is enabled automatically in development
// redux-thunk middleware is included by default
});
That's it. No manual middleware setup, no compose, no applyMiddleware. Redux Toolkit handles it.
7. Connect Redux to React
Wrap your app with the Redux Provider:
// src/main.jsx (or index.js)
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./redux/store";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);
8. Use Redux State in Components
Use useSelector to read state and useDispatch to dispatch actions:
// components/TodoApp.jsx
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
addTodo,
toggleTodo,
removeTodo,
clearCompleted,
} from "../redux/features/todos/todosSlice";
export default function TodoApp() {
const [input, setInput] = useState("");
const todos = useSelector((state) => state.todos.items);
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
dispatch(addTodo(input.trim()));
setInput("");
}
};
return (
<div>
<h2>Todo App</h2>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
{todos.length === 0 && <p>No todos yet. Add one above.</p>}
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
cursor: "pointer",
}}
onClick={() => dispatch(toggleTodo(todo.id))}
>
{todo.text}
</span>
<button onClick={() => dispatch(removeTodo(todo.id))}>Remove</button>
</li>
))}
</ul>
{todos.some((t) => t.completed) && (
<button onClick={() => dispatch(clearCompleted())}>
Clear completed
</button>
)}
</div>
);
}
For async data, dispatch the thunk and handle loading/error states:
// components/UsersList.jsx
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchUsers } from "../redux/features/users/usersSlice";
export default function UsersList() {
const dispatch = useDispatch();
const { data: users, loading, error } = useSelector((state) => state.users);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <p>Loading users...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
Adding Redux Persist
Redux state resets on every page reload. Redux Persist saves the store to localStorage (or another storage engine) so data survives browser refreshes.
Setup
// redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage"; // localStorage
import rootReducer from "./rootReducer";
const persistConfig = {
key: "root",
storage,
whitelist: ["todos"], // Only persist the todos slice, not users
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Redux Persist uses non-serializable values internally
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
},
}),
});
export const persistor = persistStore(store);
Wrap your app
// src/main.jsx
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./redux/store";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<PersistGate loading={<div>Loading...</div>} persistor={persistor}>
<App />
</PersistGate>
</Provider>
);
When to use Redux Persist
- Authentication tokens — keep users logged in across page reloads
- User preferences — dark mode, language, sidebar state
- Form drafts — save partially completed forms
- Shopping cart — items survive browser refresh
When NOT to persist
- Server data (users list, products) — fetch fresh from the API instead
- UI state (modal open/closed, hover states) — reset on reload
- Sensitive data — don't store passwords or secrets in localStorage
Common Mistakes to Avoid
-
Putting everything in Redux. Not all state belongs in Redux. Form input values, UI toggle states, and temporary loading indicators are usually better as local component state with
useState. -
Not using the
whitelist/blacklistin Redux Persist. Persisting your entire store (including server data, loading states, and errors) leads to stale data and confusing bugs on reload. -
Mutating state outside of reducers. Even though Immer lets you write "mutating" code inside
createSlicereducers, you should never mutate Redux state directly in components. Always usedispatch. -
Fetching data in components when you could use
createAsyncThunk. Centralizing your API calls in thunks makes them reusable, testable, and keeps your components focused on rendering. -
Creating too many slices. Each slice should represent a feature domain (auth, cart, users), not individual UI components. If you have a
buttonSlice, you've gone too far.
Final Thoughts
Redux has a reputation for being complex, but Redux Toolkit has dramatically simplified the setup. With createSlice for reducers, createAsyncThunk for API calls, and Redux Persist for data persistence, you can build a robust state management system in under an hour.
For small apps, React's built-in useState and useContext are often enough. But when your application grows — authentication flows, multi-entity data, complex workflows, offline support — Redux Toolkit gives you a predictable, debuggable, and scalable foundation.
Keep Learning
- Wondering whether to use React or Next.js for your project? Read our React vs Next.js comparison for 2025.
- Using Next.js? Follow the App Router best practices guide for production-ready patterns.
- Building for global users? Learn how to localize your React app with react-i18next.
- Ready to deploy? Here's how to host a static site on a VPS using Apache.
