Skip to main content

🧰 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:

flowchart TB Redux["Redux Core"] -->|wrapped & enhanced by| RTK["Redux Toolkit"] RTK --> ConfigureStore["configureStore()
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 the state draft or return a brand-new value β€” never both in the same reducer. Mixing the two (mutating and then also returning 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:

pie showData title Approx. lines of code for the same feature "Classic Redux" : 100 "Redux Toolkit" : 40

βœ… 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.

  1. Swap createStore for configureStore (your existing root reducer still works).
  2. Convert one reducer at a time to createSlice.
  3. Replace hand-written thunks with createAsyncThunk.
  4. 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's middleware callback, 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 createSlice case 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:

  1. Create a single counterSlice.js using createSlice that produces the same three actions.
  2. Rewrite the store with configureStore, mounting the slice under the counter key.
  3. 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 createStore code is legacy.
  • configureStore replaces manual store wiring; createSlice replaces 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

πŸš€ 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.