π§° Redux Toolkit Overview and Benefits
Classic Redux is powerful but famously verbose β action constants, hand-written switch reducers, manual immutable updates, middleware wiring. Redux Toolkit (RTK) is the Redux team's own answer: an opinionated, batteries-included toolset that keeps everything you love about Redux while deleting most of the ceremony.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why Redux Toolkit exists and the three complaints about classic Redux it was built to fix
- Describe RTK's core APIs β
configureStore,createSlice,createAsyncThunk, and RTK Query β and what each replaces - Read a "before RTK / after RTK" comparison and identify exactly which boilerplate disappeared
- Understand how Immer lets you write "mutating" reducer code that stays immutable
- Choose an incremental migration strategy for moving an existing Redux app onto RTK
Estimated Time: 30β40 minutes β’ Difficulty: Intermediate
Hands-on: Refactor a legacy counter reducer + store into a modern RTK slice.
In This Lesson
Why Redux Toolkit Exists
Redux Toolkit is the official, opinionated, batteries-included toolset for efficient Redux development. It is now the recommended way to write Redux logic β the classic createStore/hand-written-reducer style is considered legacy. RTK was created to silence three complaints that dogged Redux for years:
- "Configuring a Redux store is too complicated."
- "I have to add a lot of packages to get Redux to do anything useful."
- "Redux requires too much boilerplate code."
π‘ A useful analogy β the homebuilder's kit. Redux Core is a pile of raw materials: lumber, nails, concrete, and hand tools. Total flexibility, but you make every decision and perform every step yourself. Redux Toolkit is a premium kit with pre-made wall frames (createSlice), power tools instead of hand tools (Immer for immutability), best-practice templates (sensible middleware defaults), and an experienced crew (opinionated defaults). You still design the house β you just skip the repetitive labor and the rookie mistakes.
Install it alongside the React bindings β the same react-redux package you already use:
npm install @reduxjs/toolkit react-redux
# or
yarn add @reduxjs/toolkit react-redux
π Key Terms
Boilerplate: repetitive setup code that is necessary but adds little meaning β the stuff RTK generates for you.
Opinionated: the library makes good default choices so you don't have to configure everything by hand.
Immer: a library that lets you write code that looks like it mutates state but actually produces a new immutable copy.
The RTK Toolbox at a Glance
Redux Toolkit builds on top of the Redux core β it doesn't replace it. Think of the core as the engine and RTK as the well-designed dashboard, pedals, and power steering wrapped around it. Here is what's in the box:
one-call store setup"] RTK --> CreateSlice["createSlice()
reducers + actions together"] RTK --> Thunk["createAsyncThunk()
async data flows"] RTK --> Immer["Immer
write mutable, stays immutable"] RTK --> Devtools["DevTools
wired up automatically"] RTK --> RTKQuery["RTK Query
data fetching & caching"]
You won't use every tool on every project, but they share one design goal: write less code, and make the code you do write harder to get wrong. The next few sections walk through the ones you'll reach for most.
configureStore: Painless Setup
configureStore wraps the old createStore with sensible defaults. In one call it combines your slice reducers, adds the thunk middleware, wires up the Redux DevTools Extension, and β in development β installs guards that catch accidental state mutations and non-serializable values.
Compare the classic setup with the RTK version:
// β Classic Redux β lots of manual wiring
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import { usersReducer, postsReducer, commentsReducer } from './reducers';
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
});
const composedEnhancer = composeWithDevTools(applyMiddleware(thunkMiddleware));
const store = createStore(rootReducer, composedEnhancer);
// β
Redux Toolkit β same result, far less code
import { configureStore } from '@reduxjs/toolkit';
import usersReducer from '../features/users/usersSlice';
import postsReducer from '../features/posts/postsSlice';
import commentsReducer from '../features/comments/commentsSlice';
export const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
},
// Thunk middleware, DevTools, and dev-mode safety checks are all on by default.
});
// TypeScript users get these two lines for typed hooks:
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
β What you got for free
Combined reducers, thunk middleware, DevTools integration, and development-only checks for immutability and serializability β none of which you had to install or configure. Three imports became one.
createSlice & Immer
createSlice is the crown jewel of RTK. In a single call it generates your action types, your action creators, and your reducer β all named consistently. You describe the "cases" as plain functions, and RTK builds the rest.
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
// RTK uses Immer, so "mutating" code here is safe and immutable.
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
// Action creators are generated for you β export them.
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// The slice's reducer is the default export.
export default counterSlice.reducer;
That handful of lines replaces a separate action-types file, three action creators, and a switch-statement reducer. Notice the action type names are generated automatically: counter/increment, counter/decrement, and so on β the slice name plus the reducer key.
The Immer superpower
In classic Redux you must never mutate state; you spread and copy by hand. That produces correct but noisy code. RTK runs your reducers through Immer, which lets you write what looks like a direct mutation while it quietly produces a brand-new immutable state.
// β Without Immer β manual copying everywhere
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'todos/toggled':
return state.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
default:
return state;
}
};
// β
With Immer (inside createSlice) β read it like plain JavaScript
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
toggled: (state, action) => {
const todo = state.find((t) => t.id === action.payload);
todo.completed = !todo.completed; // looks mutable, is actually immutable
},
},
});
β οΈ The one Immer rule. Inside a reducer you may either mutate thestatedraft or return a brand-new value β never both in the same reducer. Mixing the two (mutating and then alsoreturning something) is the most common Immer bug.
Async: Thunks & RTK Query
Redux is synchronous, so anything involving the network needs middleware. RTK ships two answers, depending on how much you need.
createAsyncThunk β for hand-managed async
When you want full control over the state shape, createAsyncThunk generates the pending, fulfilled, and rejected action types and dispatches them around your promise. You handle them in the slice's extraReducers:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId) => {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
);
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = 'loading';
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.entities[action.payload.id] = action.payload;
state.loading = 'idle';
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.error.message;
});
},
});
export default usersSlice.reducer;
We'll dedicate a whole lesson to createAsyncThunk next, so this is just a preview.
RTK Query β for data fetching you barely write
If your async work is mostly "fetch this data, cache it, and keep it fresh," RTK Query removes almost all of the hand-written code. You declare endpoints once and RTK Query generates React hooks with caching, request de-duplication, and re-fetching baked in:
// features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => 'posts',
providesTags: ['Post'],
}),
addPost: builder.mutation({
query: (newPost) => ({ url: 'posts', method: 'POST', body: newPost }),
invalidatesTags: ['Post'],
}),
}),
});
// Hooks are generated from your endpoint names.
export const { useGetPostsQuery, useAddPostMutation } = apiSlice;
// Using the generated hook in a component
function PostsList() {
const { data: posts = [], isLoading, isError, error } = useGetPostsQuery();
if (isLoading) return <p>Loadingβ¦</p>;
if (isError) return <p>Error: {error.toString()}</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
π‘ Which async tool?
Reach for RTK Query when the task is "load server data and keep it cached" β it deletes the most code. Reach for createAsyncThunk when you need custom state shapes, multi-step orchestration, or side effects that don't map cleanly to a single endpoint.
Classic Redux vs. RTK
Task by task, here is what RTK automates. Every row is a chunk of code you no longer have to write or maintain:
| Task | Classic Redux | Redux Toolkit |
|---|---|---|
| Store setup | Manual combineReducers, applyMiddleware, DevTools compose |
One configureStore call |
| Actions | Type constants + hand-written creators | Generated by createSlice |
| Immutable updates | Manual spreading: {...state, ...} |
"Mutable" code via Immer |
| Async logic | Install thunk + write action creators | createAsyncThunk or RTK Query |
| DevTools | Manual configuration | On automatically |
| Normalized entities | Hand-rolled by-id maps | createEntityAdapter |
| Data fetching & caching | Custom thunks per endpoint | RTK Query hooks |
The cumulative effect is dramatic. A feature that took a hundred lines of Redux plumbing typically drops to well under half that with RTK β and the remaining code is the part that actually describes your app:
β Beyond fewer lines
RTK also bakes in best practices you'd otherwise have to remember: consistent action-type namespacing, guaranteed immutability, DevTools, and first-class TypeScript types. Less code and fewer footguns.
Migrating Incrementally
You don't have to rewrite an existing Redux app in one weekend. RTK is designed to slot in gradually β the old and new styles coexist happily in the same store.
- Swap
createStoreforconfigureStore(your existing root reducer still works). - Convert one reducer at a time to
createSlice. - Replace hand-written thunks with
createAsyncThunk. - Adopt RTK Query for new data-fetching as you go.
// Step 1 β drop-in store upgrade. Your old rootReducer is untouched.
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
export const store = configureStore({
reducer: rootReducer, // thunk + DevTools now included automatically
});
β οΈ Watch these during migration
- Custom middleware β pass it through
configureStore'smiddlewarecallback, appending to the defaults rather than replacing them. - Non-serializable values in state β RTK's dev checks will warn you; that's a feature, but legacy code may trip it at first.
- Complex reducer logic β some intricate reducers need a little reshaping to fit the
createSlicecase model.
Hands-on Exercise
ποΈ Refactor: Legacy Reducer β RTK Slice
Objective: Convert a classic Redux counter (store + reducer + actions) into a modern RTK slice, proving to yourself how much code disappears.
Starting point (classic Redux):
// actions.js
export const increment = () => ({ type: 'counter/increment' });
export const decrement = () => ({ type: 'counter/decrement' });
export const addAmount = (n) => ({ type: 'counter/addAmount', payload: n });
// reducer.js
const initial = { value: 0 };
export function counterReducer(state = initial, action) {
switch (action.type) {
case 'counter/increment': return { ...state, value: state.value + 1 };
case 'counter/decrement': return { ...state, value: state.value - 1 };
case 'counter/addAmount': return { ...state, value: state.value + action.payload };
default: return state;
}
}
// store.js
import { createStore } from 'redux';
import { counterReducer } from './reducer';
export const store = createStore(counterReducer);
Your task:
- Create a single
counterSlice.jsusingcreateSlicethat produces the same three actions. - Rewrite the store with
configureStore, mounting the slice under thecounterkey. - Confirm the generated action types are still
counter/increment, etc.
π‘ Hint
The slice name becomes the action-type prefix, and each key in reducers becomes the suffix. Because Immer is active, state.value += 1 is all you need inside each reducer β no spreading.
β Solution
// counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
addAmount: (state, action) => { state.value += action.payload; },
},
});
export const { increment, decrement, addAmount } = counterSlice.actions;
export default counterSlice.reducer;
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: { counter: counterReducer },
});
Roughly 25 lines became 15, the separate actions and reducer files collapsed into one, and you gained DevTools plus thunk middleware for free.
π― Quick Quiz
Question 1: Which single RTK function generates action types, action creators, and a reducer together?
Question 2: Inside a createSlice reducer you write state.value += 1. Why is this safe?
Question 3: Your task is simply to fetch a list from an API and keep it cached and fresh. Which RTK tool deletes the most code?
Summary & Quiz
π Key Takeaways
- Redux Toolkit is the official, recommended way to write Redux β classic
createStorecode is legacy. configureStorereplaces manual store wiring;createSlicereplaces action constants, creators, and switch reducers.- Immer lets you write "mutating" reducer code that stays fully immutable.
- For async, use createAsyncThunk for custom flows and RTK Query for cached data fetching.
- You can migrate an existing app incrementally, one reducer at a time.
π Further Reading
- Redux Toolkit β Official Documentation
- Redux Essentials Tutorial (uses RTK)
- RTK Query Overview
- Immer Documentation
π What's Next?
Now that you know why RTK exists and what's in the box, the next lesson zooms in on its most-used API. In Creating Slices with createSlice you'll build slices from scratch β payloads, prepare callbacks, extraReducers, and co-located selectors.
π Great start!
You've seen the whole toolkit. Time to master the tool you'll use most.