Skip to main content

🧰 Redux Toolkit Implementation

Classic Redux taught you the concepts — and made you type a lot to use them. Redux Toolkit (RTK) is the official, batteries-included answer: it keeps every principle intact while cutting the boilerplate dramatically. In this lesson you'll rebuild the same features with a fraction of the code.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the pain points of hand-written Redux that RTK was designed to solve
  • Set up a store with configureStore and its sensible defaults
  • Generate actions and reducers together with createSlice
  • Write "mutating" reducer logic safely thanks to Immer
  • Connect a slice to React with Provider, useSelector, and useDispatch
  • Organize code by feature and use createSelector and createEntityAdapter appropriately

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Convert the classic-Redux todo store into a single RTK slice.

In This Lesson

Why Redux Toolkit Exists

Classic Redux is powerful, but the community consistently hit the same friction:

  • Boilerplate — separate files for action types, action creators, reducers, and the store, all for one feature.
  • Configuration complexity — wiring middleware, DevTools, and enhancers by hand.
  • Fragile immutability — manual spreading ({ ...state }) is verbose and easy to get subtly wrong.
  • No standard — every codebase invented its own folder structure and conventions.
💡 An analogy: Writing Redux by hand is like making espresso from raw beans — grind, tamp, measure, time the pull. Redux Toolkit is the good automatic machine: press one button and get the same espresso, with the fiddly parts handled correctly for you.

📖 What RTK is

Redux Toolkit is the official, opinionated, "batteries-included" toolset for Redux. It is now the recommended way to write Redux — the docs steer everyone here, and the classic createStore is deprecated. RTK doesn't replace Redux's ideas; it packages them well.

Out of the box RTK gives you a small set of focused APIs:

graph TD A[Redux Toolkit] --> B[configureStore] A --> C[createSlice] A --> D[createAsyncThunk] A --> E[createSelector] A --> F[createEntityAdapter] A --> G[RTK Query]

Installation & configureStore

Install

# npm
npm install @reduxjs/toolkit react-redux

# yarn
yarn add @reduxjs/toolkit react-redux

configureStore

configureStore wraps the old createStore with good defaults. In one call it combines your slice reducers into a root reducer, adds the thunk middleware, turns on the Redux DevTools Extension, and installs development checks that warn you about accidental mutations or non-serializable values.

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer
  }
  // devTools, thunk, and dev-only checks are enabled automatically
});

export default store;

💡 Compare that to classic setup

No combineReducers, no manual applyMiddleware, no window.__REDUX_DEVTOOLS_EXTENSION__ dance. The same store that took a dozen fiddly lines is now four obvious ones.

createSlice: The Heart of RTK

createSlice is the API you'll use most. From a single configuration object it generates the reducer, the action creators, and the action type strings — everything a feature needs in one place.

graph TD A[createSlice config] --> B[name] A --> C[initialState] A --> D[reducers] A -.-> E[Generated output] E --> F[slice.reducer] E --> G[slice.actions] E --> H[action type strings]

Anatomy of a slice

import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',                 // namespaces the action types
  initialState: { value: 0 },
  reducers: {
    // key -> action type 'counter/increment'; value -> case reducer
    increment: (state) => {
      state.value += 1;            // looks like mutation — Immer makes it safe
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
    reset: (state) => {
      state.value = 0;
    }
  }
});

// Action creators are generated for you
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;

// So is the reducer
export default counterSlice.reducer;

That single call produced a reducer, four action creators, and four namespaced action types (counter/increment, and so on). The equivalent classic code would span multiple files and be several times longer.

Prepare callbacks for richer payloads

When an action needs to shape or generate part of its payload — an id, a timestamp — use the { reducer, prepare } form. The prepare function builds the payload; the reducer just applies it. This keeps impure work (like Date.now()) out of the reducer.

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer: (state, action) => {
        state.push(action.payload);
      },
      prepare: (text) => ({
        payload: {
          id: crypto.randomUUID(),
          text,
          completed: false,
          createdAt: new Date().toISOString()
        }
      })
    },
    toggleTodo: (state, action) => {
      const todo = state.find(t => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    }
  }
});

Immer & "Mutating" Reducers

The line state.value += 1 would be a bug in classic Redux. In RTK it's correct — and this is the single biggest quality-of-life win. RTK runs your reducer bodies through Immer, which hands you a special draft of the state. You "mutate" the draft freely, and Immer records those changes and produces a correct, brand-new immutable state behind the scenes.

How Immer turns drafts into immutable state Current state goes in, Immer wraps it as a draft you mutate, then produces a new immutable next state while leaving the original untouched. Current state (immutable) Draft you "mutate" it Next state (new & immutable) Immer wraps Immer produces
Figure 1 — Immer lets you write natural mutation code while still getting a new immutable object. The original state is never touched.

⚠️ One rule to remember

Inside a slice reducer you may either mutate the draft or return a new value — never both in the same reducer. And this magic only applies inside RTK reducers; anywhere else (components, thunks) you still treat state as read-only.

Connecting to React

react-redux is the binding layer. You wrap your app in a <Provider>, read state with the useSelector hook, and send actions with the useDispatch hook.

Provide the store

// index.js  (React 18+)
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import store from './app/store';

createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Read and dispatch in a component

// features/counter/Counter.jsx
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';

export function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();
  const [amount, setAmount] = useState('2');

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>

      <input value={amount} onChange={(e) => setAmount(e.target.value)} />
      <button onClick={() => dispatch(incrementByAmount(Number(amount) || 0))}>
        Add amount
      </button>
    </div>
  );
}

✅ How re-rendering works

useSelector subscribes the component to the store and re-runs your selector after every dispatch. If the selected value changed (by reference/value), the component re-renders; if not, it's skipped. Select the smallest slice of state a component needs to avoid unnecessary renders.

Selectors & Entity Adapters

Memoized selectors with createSelector

RTK re-exports createSelector from Reselect. It builds memoized selectors that only recompute when their inputs change — ideal for derived data like filtered lists, so you don't recompute (and re-render) on unrelated updates.

import { createSelector } from '@reduxjs/toolkit';

const selectTodos  = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;

export const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'COMPLETED': return todos.filter(t => t.completed);
      case 'ACTIVE':    return todos.filter(t => !t.completed);
      default:          return todos;
    }
  }
);

Normalized data with createEntityAdapter

For collections (users, posts, products) that you look up by id, createEntityAdapter stores data in a normalized { ids: [], entities: {} } shape and hands you ready-made CRUD reducers and selectors.

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';

const usersAdapter = createEntityAdapter({
  selectId: (user) => user.userId,
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({ status: 'idle', error: null }),
  reducers: {
    userAdded:     usersAdapter.addOne,
    usersReceived: usersAdapter.setAll,
    userUpdated:   usersAdapter.updateOne,
    userRemoved:   usersAdapter.removeOne
  }
});

export const { userAdded, usersReceived, userUpdated, userRemoved } = usersSlice.actions;

export const {
  selectAll: selectAllUsers,
  selectById: selectUserById,
  selectIds: selectUserIds
} = usersAdapter.getSelectors((state) => state.users);

export default usersSlice.reducer;

Best Practices

Organize by feature, not by type

Put each feature's slice and its components together, so everything about "todos" lives in one folder:

src/
  app/
    store.js
  features/
    counter/
      counterSlice.js
      Counter.jsx
    todos/
      todosSlice.js
      TodoList.jsx
✅ Do❌ Don't
Use configureStore and createSliceReach for deprecated createStore in new code
Derive data with selectors / createSelectorStore computed values that can drift out of sync
Put id/timestamp generation in a prepare callbackCall Date.now() or Math.random() in the reducer body
Consider RTK Query for server dataHand-roll caching and loading flags for every endpoint
Add TypeScript for excellent inferenceIgnore the free type-safety RTK provides

💡 RTK Query in one sentence

For fetching, caching, and syncing server data, RTK ships RTK Query — you declare endpoints and it generates hooks like useGetUsersQuery() that handle loading, caching, and refetching for you. You'll meet the async foundations it's built on in the next lesson.

Hands-on Exercise

🏋️ Convert the Todo Store to a Slice

Objective: Replace the classic-Redux todo store (from the previous lesson) with a single RTK slice plus a filter slice and a memoized selector.

Instructions:

  1. Create todosSlice with addTodo (use a prepare callback for the id/timestamp), toggleTodo, and deleteTodo.
  2. Create filterSlice with a single setFilter reducer.
  3. Wire both into configureStore.
  4. Write a selectFilteredTodos memoized selector with createSelector.
  5. Bonus: add a clearCompleted reducer.
💡 Hint

Inside slice reducers you can mutate the draft directly: state.push(...), todo.completed = !todo.completed. For delete, reassign: state.items = state.items.filter(...). Keep the id/timestamp out of the reducer by generating them in prepare.

✅ Sample solution
// features/todos/todosSlice.js
import { createSlice, createSelector } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: {
      reducer: (state, action) => { state.items.push(action.payload); },
      prepare: (text) => ({
        payload: { id: crypto.randomUUID(), text, completed: false }
      })
    },
    toggleTodo: (state, action) => {
      const t = state.items.find(t => t.id === action.payload);
      if (t) t.completed = !t.completed;
    },
    deleteTodo: (state, action) => {
      state.items = state.items.filter(t => t.id !== action.payload);
    },
    clearCompleted: (state) => {
      state.items = state.items.filter(t => !t.completed);
    }
  }
});

export const { addTodo, toggleTodo, deleteTodo, clearCompleted } = todosSlice.actions;
export default todosSlice.reducer;

// features/filter/filterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const filterSlice = createSlice({
  name: 'filter',
  initialState: 'ALL',
  reducers: { setFilter: (_state, action) => action.payload }
});
export const { setFilter } = filterSlice.actions;
export default filterSlice.reducer;

// selector
export const selectFilteredTodos = createSelector(
  [(s) => s.todos.items, (s) => s.filter],
  (items, filter) => {
    if (filter === 'ACTIVE')    return items.filter(t => !t.completed);
    if (filter === 'COMPLETED') return items.filter(t =>  t.completed);
    return items;
  }
);

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import filterReducer from '../features/filter/filterSlice';

export const store = configureStore({
  reducer: { todos: todosReducer, filter: filterReducer }
});

🎯 Quick Quiz

Question 1: Which single RTK API generates a reducer, its action creators, and its action types together?

Question 2: Why is state.value += 1 safe inside an RTK slice reducer?

Question 3: Where should you generate a new todo's id and timestamp?

Summary & Quiz

🎉 Key Takeaways

  • Redux Toolkit is the official, recommended way to write Redux — same principles, far less code.
  • configureStore gives you a store with DevTools, thunk, and dev checks by default.
  • createSlice generates the reducer, action creators, and action types from one object.
  • Immer lets you write natural "mutating" logic that produces correct immutable state.
  • Connect with Provider + useSelector + useDispatch; derive data with createSelector; normalize collections with createEntityAdapter.

📚 Further Reading

🚀 What's Next?

Everything so far has been synchronous. Real apps need to fetch data. Next you'll handle asynchronous operations in Redux with thunks and createAsyncThunk, and see how RTK Query builds on those foundations.

🎉 Great progress!

You can now write real-world Redux quickly. Time to make it talk to a server.