Skip to main content

🧰 Introduction to Redux Toolkit

You've felt the pain: action-type constants, action creators, hand-written switch reducers, spread operators everywhere, and a store configuration ritual you copy from an old project. Redux Toolkit is the Redux team's own answer to that pain — the same predictable state container, but with the ceremony removed. This lesson is the modern payoff of the week: less code, fewer bugs, best practices baked in.

Week 6 · Thursday: Redux Toolkit · Lecture 1

🎯 Learning Objectives

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

  • Explain what Redux Toolkit (RTK) is and the specific problems it solves
  • Set up a store with configureStore and understand what it wires up for you
  • Write a feature slice with createSlice and read its generated actions and reducer
  • Explain correctly why RTK's "mutating" reducers are still immutable (Immer)
  • Handle async data with createAsyncThunk and builder.addCase
  • Recognize when to reach for createEntityAdapter and RTK Query

Estimated Time: 60 minutes

Practice: Rebuild a counter and a users feature with a single slice and a store.

In This Lesson

What is Redux Toolkit?

Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. It's the package the Redux maintainers now recommend for all Redux work — plain Redux is still under the hood, but you rarely touch it directly. RTK wraps the raw APIs in higher-level functions that enforce the community's hard-won best practices by default.

🧰 The Power Tools Analogy

Hand-rolled Redux is like building a deck with a hand saw and a screwdriver: total control, but slow, tiring, and easy to cut crooked. Redux Toolkit is the cordless circular saw and impact driver — the same deck, built faster and straighter, with safety guards built in.

  • configureStore — a pre-configured workshop with the good defaults already set up
  • createSlice — one tool that produces your actions and your reducer together
  • createAsyncThunk — automated machinery for async request lifecycles
  • Immer — a safety guard that turns "mutations" into correct immutable updates

Everything you learned about actions, reducers, and the store this week still applies. RTK doesn't replace those ideas — it removes the repetitive typing around them.

Why RTK? Before & After

The classic complaints about Redux — "too much boilerplate," "too many files," "I keep forgetting the spread operator" — are exactly what RTK targets.

graph LR A["Hand-rolled Redux pain"] --> B["Action type constants"] A --> C["Verbose switch reducers"] A --> D["Manual immutable spreads"] A --> E["Thunk + DevTools setup"] F["Redux Toolkit"] --> G["Actions auto-generated"] F --> H["Reducers via createSlice"] F --> I["Immer handles immutability"] F --> J["configureStore does setup"] B --> G C --> H D --> I E --> J

Here is the same "fetch users" feature written both ways. Notice how much disappears.

Before — traditional Redux (~60 lines)

// 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 });

// Thunk
const fetchUsers = () => async (dispatch) => {
  dispatch(fetchUsersRequest());
  try {
    const res = await api.getUsers();
    dispatch(fetchUsersSuccess(res.data));
  } catch (err) {
    dispatch(fetchUsersFailure(err.message));
  }
};

// Reducer — every case must return a brand-new object
const initialState = { users: [], loading: false, error: null };
function 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 — thunk + DevTools wired by hand
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
const store = createStore(usersReducer, composeWithDevTools(applyMiddleware(thunk)));

After — Redux Toolkit (~25 lines)

import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
import api from './api';

// One async thunk generates pending/fulfilled/rejected actions for you
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const res = await api.getUsers();
  return res.data; // becomes action.payload on "fulfilled"
});

const usersSlice = createSlice({
  name: 'users',
  initialState: { users: [], loading: false, error: null },
  reducers: {},                       // no sync actions needed here
  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; });
  }
});

// thunk middleware + DevTools + dev-mode safety checks: all automatic
export const store = configureStore({ reducer: { users: usersSlice.reducer } });

✅ What you just removed

No action-type strings, no hand-written action creators, no switch, no manual spreads, and no manual middleware wiring. Same behavior, a fraction of the surface area to get wrong.

configureStore

configureStore is the RTK replacement for the classic createStore. You hand it a reducer map and it does the tedious setup — the equivalent of a workshop that arrives with the workbench already assembled.

import { configureStore } from '@reduxjs/toolkit';
import usersReducer from './features/users/usersSlice';
import postsReducer from './features/posts/postsSlice';
import authReducer  from './features/auth/authSlice';

export const store = configureStore({
  reducer: {
    users: usersReducer,   // state.users
    posts: postsReducer,   // state.posts
    auth:  authReducer     // state.auth
  }
});

💡 What configureStore sets up automatically

  • Combines your slice reducers (no manual combineReducers)
  • Adds the thunk middleware so async logic works out of the box
  • Adds dev-only checks that warn on accidental mutation and non-serializable values
  • Wires up the Redux DevTools browser extension

You can still customize every piece when you need to — middleware, preloaded state, DevTools toggling:

const store = configureStore({
  reducer: { users: usersReducer, auth: authReducer },
  // Start from the good defaults, then tweak or append:
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['auth/tokenReceived'],
        ignoredPaths: ['auth.tokenExpiry']
      }
    }).concat(loggerMiddleware),
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState: {
    auth: { user: null, token: localStorage.getItem('token') }
  }
});
💡 Rule of thumb: Reach for the plain reducer-only form first. Only add a middleware callback when you have a concrete reason — and always start it from getDefaultMiddleware() so you don't accidentally drop the thunk and safety checks.

createSlice

A slice is one feature's corner of the store — its state plus the reducers that change it. createSlice takes a name, an initial state, and a set of reducer functions, and hands back the action creators and the reducer, fully generated. This is the single most important RTK API to internalize.

createSlice takes a config object and generates action creators and a reducer config in name: 'counter' initialState reducers: { increment, decrement } createSlice ( config ) .actions increment() decrement() .reducer give to configureStore
One config object goes in; a set of action creators and a reducer come out — action types like 'counter/increment' are derived automatically from the slice name and each reducer key.

A minimal slice

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

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1;              // looks like mutation — it's not (see Immer below)
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload; // the argument you dispatch arrives as action.payload
    }
  }
});

// Action creators — generated for you, one per reducer key:
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// The reducer — hand this to configureStore:
export default counterSlice.reducer;

Dispatching it

dispatch(increment());            // action = { type: 'counter/increment' }
dispatch(incrementByAmount(5));   // action = { type: 'counter/incrementByAmount', payload: 5 }
// state.counter.value goes 0 → 1 → 6

The prepare callback — customizing the payload

Sometimes the value you want to dispatch isn't the value you want to store. A prepare callback runs first, shaping the payload (adding an id, a timestamp) before the reducer sees it.

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: {
      // reducer receives the already-prepared action
      reducer: (state, action) => {
        state.items.push(action.payload);
      },
      // prepare shapes the payload; called with the args you pass to addTodo(...)
      prepare: (text) => ({
        payload: {
          id: crypto.randomUUID(),
          text,
          completed: false,
          createdAt: new Date().toISOString()
        }
      })
    }
  }
});

// Component just passes the text; id + timestamps are added centrally:
dispatch(todosSlice.actions.addTodo('Learn Redux Toolkit'));
💡 Why bother: keeping id/timestamp generation in prepare means every component that adds a todo produces a consistently shaped action — no duplicated boilerplate at each call site.

The Immer "Mutation" Trick

The line state.value += 1 should alarm you — Redux reducers must be pure and must never mutate state. So how is this allowed? RTK runs your reducers through a library called Immer.

Immer hands your reducer a draft: a special proxy that records the changes you make. You write plain, readable "mutations" against the draft, and when the reducer returns, Immer produces a brand-new immutable state with exactly those changes applied — the original state is never touched.

sequenceDiagram participant R as Your reducer participant I as Immer draft participant S as New state R->>I: state.value += 1 Note over I: records the change,
original untouched I->>S: produce new immutable state S-->>R: returned to the store

So both of these do the same correct, immutable thing — but the RTK version is far easier to read:

// Plain Redux — manual immutable update
function toggleTodo(state, action) {
  return {
    ...state,
    items: state.items.map(todo =>
      todo.id === action.payload
        ? { ...todo, completed: !todo.completed }
        : todo
    )
  };
}

// RTK + Immer — "mutate" the draft, get the same immutable result
toggleTodo: (state, action) => {
  const todo = state.items.find(t => t.id === action.payload);
  if (todo) todo.completed = !todo.completed;
}

⚠️ The one Immer rule that trips everyone up

Inside an Immer reducer, either mutate the draft or return a brand-new value — never both. Mutating and then return state throws an error.

// ❌ Mutated AND returned — Immer throws
reset: (state) => { state.value = 0; return state; }

// ✅ Mutate only (no return)
reset: (state) => { state.value = 0; }

// ✅ Or return a brand-new value only (don't touch the draft)
reset: () => ({ value: 0 });

createAsyncThunk (Preview)

Real apps fetch data, and fetches take time and can fail. createAsyncThunk models that lifecycle as three actions — pending, fulfilled, and rejected — generated from a single async function. You handle those actions in your slice's extraReducers using the builder.

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { client } from '../../api/client';

// The prefix 'users/fetchById' seeds the three generated action types.
export const fetchUserById = createAsyncThunk(
  'users/fetchById',
  async (userId) => {
    const res = await client.get(`/users/${userId}`);
    return res.data;              // resolves → fulfilled, payload = res.data
  }                               // throws   → rejected,  action.error is set
);

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: {}, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending,   (state) => { state.loading = true; state.error = null; })
      .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;
      });
  }
});

💡 reducers vs extraReducers

Use the reducers field for actions this slice owns (it generates their action creators). Use extraReducers to respond to actions defined elsewhere — like a thunk's pending/fulfilled/rejected, or another slice's actions. The next lesson goes deep on all of this.

Entity Adapters

When a slice manages a collection — users, posts, todos — you usually want it stored normalized (a lookup object keyed by id, plus an ordered list of ids) rather than a plain array. createEntityAdapter gives you that shape and pre-built reducers and selectors for free.

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

const usersAdapter = createEntityAdapter({
  sortComparer: (a, b) => a.name.localeCompare(b.name)   // keep the list sorted
});

const usersSlice = createSlice({
  name: 'users',
  // getInitialState() → { ids: [], entities: {} } (+ any extra fields you add)
  initialState: usersAdapter.getInitialState({ loading: false }),
  reducers: {
    userAdded:    usersAdapter.addOne,     // ready-made CRUD reducers
    userUpdated:  usersAdapter.updateOne,
    userRemoved:  usersAdapter.removeOne,
    usersLoaded:  usersAdapter.setAll
  }
});

// Pre-built, memoized selectors — no need to hand-write them:
export const {
  selectAll: selectAllUsers,
  selectById: selectUserById,
  selectIds: selectUserIds
} = usersAdapter.getSelectors((state) => state.users);
💡 When to use it: reach for an entity adapter once a feature does real CRUD on a list. For a single object or a tiny fixed array, a plain slice is simpler. We'll return to normalized state in the next lesson's follow-up.

Practice & Quiz

🏋️ Exercise 1: A counter slice + store

Goal: Write a counter slice with increment, decrement, and incrementByAmount, then create a store from it.

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

// TODO: create the slice, export its actions, build the store
// Then verify:
// dispatch(increment());          value → 1
// dispatch(incrementByAmount(9)); value → 10
💡 Hint

Each key in reducers becomes an action creator on slice.actions. The dispatched argument arrives as action.payload. Pass { counter: counterSlice.reducer } to configureStore.

✅ Solution
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    incrementByAmount: (state, action) => { state.value += action.payload; }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

export const store = configureStore({
  reducer: { counter: counterSlice.reducer }
});

store.dispatch(increment());
store.dispatch(incrementByAmount(9));
console.log(store.getState().counter.value); // 10

🏋️ Exercise 2: Spot the Immer bug

Goal: This reducer throws at runtime. Explain why and fix it.

setUser: (state, action) => {
  state.user = action.payload;
  return state;   // 🐛
}
✅ Solution

You mutated the draft (state.user = ...) and returned it. Immer forbids doing both — drop the return.

setUser: (state, action) => {
  state.user = action.payload;   // mutate the draft only, no return
}

🎯 Quick Quiz

Question 1: What does createSlice generate for you?

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

Question 3: Which field of a slice handles a createAsyncThunk's pending/fulfilled/rejected actions?

Best Practices & Pitfalls

✅ Do

  • Use configureStore and createSlice for all new Redux code
  • Keep each slice focused on a single feature; organize by feature folder
  • Co-locate selectors in the slice file so components don't reach into state shape
  • Reach for createEntityAdapter once a feature does CRUD on a collection
  • Start custom middleware from getDefaultMiddleware() so you keep thunk + dev checks

❌ Don't

  • Mutate state outside a createSlice reducer — the Immer magic only applies inside
  • Both mutate the draft and return it from the same reducer
  • Put every piece of state in Redux — local component state is still fine and often better
  • Hand-write action-type constants and switch reducers for new features

⚠️ Suggested folder structure

src/
  app/
    store.js            // configureStore lives here
  features/
    users/
      usersSlice.js     // slice + thunks + selectors together
      UsersList.jsx
    posts/
      postsSlice.js
      PostsList.jsx
  api/
    client.js

Grouping by feature (not by "actions/reducers/selectors" type folders) keeps everything a feature needs in one place.

Summary

🎉 Key Takeaways

  • Redux Toolkit is the official, recommended way to write Redux — plain Redux under the hood, boilerplate removed
  • configureStore wires up thunk middleware, dev-mode safety checks, and DevTools for you
  • createSlice generates action creators and a reducer from one config object
  • Immer makes "mutating" reducers safe by producing new immutable state from a recorded draft
  • createAsyncThunk models async as pending/fulfilled/rejected, handled in extraReducers
  • createEntityAdapter gives normalized collections free CRUD reducers and selectors

📚 Additional Resources

🚀 What's Next?

You've seen the whole toolkit from altitude. Next we zoom into the two APIs that do the heavy lifting: createSlice and createAsyncThunk — prepare callbacks, the builder in depth, matchers, thunk error handling with rejectWithValue, and integrating both into one real feature.

🎉 Toolkit unlocked!

You now know why RTK exists and what each core API buys you. From here on, this is simply how you write Redux.