Redux integration in React.js
Frontend DevelopmentUpdated: 12 min read

By Akshay Singh

Share this article:

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:

  1. Too much boilerplate — action types, action creators, reducers, switch statements
  2. Too many packages — redux, react-redux, redux-thunk, immer, reselect
  3. 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 definition
  • createAsyncThunk — 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 + useReducer is simpler)
  • You're fetching server data (consider React Query or SWR instead)

A good rule of thumb: if you're using useContext with useReducer and 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 true
  • users/fetchUsers/fulfilled → stores the data
  • users/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

  1. 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.

  2. Not using the whitelist/blacklist in Redux Persist. Persisting your entire store (including server data, loading states, and errors) leads to stale data and confusing bugs on reload.

  3. Mutating state outside of reducers. Even though Immer lets you write "mutating" code inside createSlice reducers, you should never mutate Redux state directly in components. Always use dispatch.

  4. 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.

  5. 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

React.jsReduxRedux ToolkitState Management
TheDailyDevsTheDailyDevs
TheDailyDevs is a developer-first blog and knowledge hub created by passionate engineers to share real-world development tips, deep-dive tutorials, industry insights, and hands-on solutions to everyday coding challenges. Whether you're building apps, exploring new frameworks, or leveling up your dev game, you'll find practical, no-fluff content here, updated daily.