🧰 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
configureStoreand understand what it wires up for you - Write a feature slice with
createSliceand read its generated actions and reducer - Explain correctly why RTK's "mutating" reducers are still immutable (Immer)
- Handle async data with
createAsyncThunkandbuilder.addCase - Recognize when to reach for
createEntityAdapterand 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 upcreateSlice— one tool that produces your actions and your reducer togethercreateAsyncThunk— 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.
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 plainreducer-only form first. Only add amiddlewarecallback when you have a concrete reason — and always start it fromgetDefaultMiddleware()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.
'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.
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
configureStoreandcreateSlicefor 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
createEntityAdapteronce a feature does CRUD on a collection - Start custom middleware from
getDefaultMiddleware()so you keep thunk + dev checks
❌ Don't
- Mutate state outside a
createSlicereducer — the Immer magic only applies inside - Both mutate the draft and
returnit 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
configureStorewires up thunk middleware, dev-mode safety checks, and DevTools for youcreateSlicegenerates action creators and a reducer from one config object- Immer makes "mutating" reducers safe by producing new immutable state from a recorded draft
createAsyncThunkmodels async as pending/fulfilled/rejected, handled inextraReducerscreateEntityAdaptergives normalized collections free CRUD reducers and selectors
📚 Additional Resources
- Redux Toolkit — official documentation
- Redux Toolkit — Quick Start tutorial
- Writing Reducers with Immer
- Redux Style Guide — official best practices
🚀 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.