Engineering Notes
Home/Frontend/Ecosystem/Redux Toolkit
Frontend · Ecosystem

Redux Toolkit

Redux Toolkit keeps Redux's model and removes the boilerplate. Compare a plain reducer with createSlice, see Immer turn 'mutating' code into immutable updates, and generate loading states with createAsyncThunk.

🎨 Frontend● Intermediate⏱ 7 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The same model with less boilerplate
  • createSlice and Immer
  • configureStore defaults
  • createAsyncThunk and the contrast with plain Redux

📚 Before you start

1The Same Model, Radically Less Boilerplate

Redux Toolkit (RTK) is the officially recommended way to write Redux — it does not replace Redux's model from the previous topic (single store, dispatched actions, pure reducers), it generates the boilerplate that model requires. Everything RTK produces is still plain Redux underneath: a createSlice call still ends up as an ordinary reducer function and ordinary action creators, usable with plain useSelector/useDispatch exactly as before.

ts
// Plain Redux (from the previous topic): 3 separate things to write and keep in sync
const INCREMENT = "counter/increment";
function increment() { return { type: INCREMENT }; }
function counterReducer(state = { count: 0 }, action: any) {
  switch (action.type) {
    case INCREMENT: return { count: state.count + 1 };
    default: return state;
  }
}
ts
// Redux Toolkit: ONE createSlice call generates the reducer AND the action creator together
import { createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: { count: 0 },
  reducers: {
    increment: (state) => { state.count += 1; }, // looks like mutation — see section 2
  },
});

export const { increment } = counterSlice.actions; // auto-generated action creator
export default counterSlice.reducer;                 // auto-generated reducer

2createSlice and the Immer-Powered "Mutating" Syntax

The previous topic was emphatic: reducers must never mutate state. RTK's createSlice reducer functions look like they violate that rule (state.count += 1) — but they don't, because createSlice wraps every reducer function with Immer, a library that lets you write code that appears to mutate a draft object, while actually producing a correct, brand-new immutable state object behind the scenes.

ts
const todosSlice = createSlice({
  name: "todos",
  initialState: [] as { id: number; text: string; done: boolean }[],
  reducers: {
    add: (state, action: { payload: { id: number; text: string } }) => {
      state.push({ id: action.payload.id, text: action.payload.text, done: false }); // looks like a mutating .push()
    },
    toggle: (state, action: { payload: number }) => {
      const todo = state.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done; // looks like a direct property mutation
    },
  },
});

Compare this to the previous topic's hand-written todosReducer, which had to spread arrays and objects explicitly ([...state, newTodo], {...t, done: !t.done}) to avoid mutating. Immer lets the reducer body read like the "obviously correct, obviously wrong for Redux" version, while Immer's proxy-based tracking produces the properly immutable result — the payoff is large for deeply nested state, where hand-written immutable updates get genuinely hard to read correctly.

3configureStore: Sensible Defaults, One Function Call

ts
import { configureStore } from "@reduxjs/toolkit";

const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    todos: todosSlice.reducer,
  },
});

configureStore replaces createStore + combineReducers + manually calling applyMiddleware(thunk) + manually wiring Redux DevTools — it does all of that automatically: thunk middleware is included by default, DevTools integration is enabled by default (in development), and it adds development-only middleware that warns on accidental state mutation outside a slice reducer or non-serializable values landing in the store, catching classes of bugs plain createStore would let through silently.

4createAsyncThunk: Async Logic Without Hand-Rolled Loading States

ts
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

const fetchUser = createAsyncThunk("user/fetch", async (userId: string) => {
  const res = await fetch(`/api/users/${userId}`);
  return res.json(); // becomes action.payload in the fulfilled case
});

const userSlice = createSlice({
  name: "user",
  initialState: { data: null as any, status: "idle" as "idle" | "loading" | "succeeded" | "failed" },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.status = "loading"; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state) => { state.status = "failed"; });
  },
});

dispatch(fetchUser("42")); // dispatches pending immediately, then fulfilled or rejected

createAsyncThunk takes a type-prefix string and an async payload-creator function, and automatically generates three action types around it (pending, fulfilled, rejected) dispatched at the right moments of the Promise's lifecycle — this is the same underlying thunk-middleware mechanism from the previous topic's Part 2, but with the loading/success/error action bookkeeping generated for you instead of hand-written per async operation.

5Contrast Directly With Plain Redux's Boilerplate

text
Plain Redux, adding ONE new capability (e.g. "toggle a todo"):
  1. add a type constant
  2. write an action creator function
  3. add a case branch to the reducer's switch, with manual immutable spreading

Redux Toolkit, the same capability:
  1. add one key to the slice's `reducers` object, mutate the draft directly
     (action creator + action type string are both generated automatically)

The reduction isn't just fewer lines — it's fewer places a single logical change has to touch, which is exactly the kind of boilerplate that grows error-prone (forgetting to add the switch case, a typo in a type-constant string used in two places) as an app's slice count grows.

Under the HoodHow it works internally▾

Immer works by wrapping the state you pass in a JavaScript Proxy, recording every property access and assignment made against it, and only ever handing Redux a real, newly-constructed object. When a createSlice reducer runs, RTK calls it with a Proxy standing in for the real state — every state.count += 1 or state.push(...) you write is actually intercepted by the Proxy's trap handlers, which record "this path was written to, with this new value" rather than mutating anything real. Once your reducer function returns, Immer walks its recorded changes and produces a brand-new object graph, structurally sharing (reusing the same references for) every part of the state tree that wasn't touched — so a toggle reducer that flips one todo's done flag still returns a new todos array with a new object for that one todo, but every other todo object in that array is the exact same reference as before, exactly matching what a hand-written immutable update would have produced.

This is why the "mutating" syntax is safe ONLY inside a createSlice reducer, and dangerous anywhere else. The Proxy-and-draft machinery is set up by RTK specifically around the function you pass to reducers; mutating a plain object you pulled out of useSelector() or a state value sitting in a useState elsewhere in your app is a real, ordinary JavaScript mutation with none of Immer's safety net — RTK does not globally intercept every object mutation in your codebase, only the ones happening inside its own reducer-wrapping mechanism.

Immer runs the reducer against a Proxy draft and produces a new state that copies only the changed path while sharing untouched branches
Immer runs the reducer against a Proxy draft and produces a new state that copies only the changed path while sharing untouched branches

⚠️ Common Pitfalls

  1. Mutating state OUTSIDE a createSlice reducer (e.g. directly mutating a value pulled from useSelector) expecting Immer to catch it — Immer's proxy wrapping only applies inside the reducer function RTK controls; mutating elsewhere is a real, unguarded mutation.
  2. Returning a new value AND mutating the draft in the same reducer — Immer supports either pattern (mutate the draft, or return a brand-new value) but mixing both in one reducer produces a runtime error, since Immer can't reconcile "you changed the draft" with "you also returned something else."
  3. Forgetting extraReducers for createAsyncThunk's generated action types — defining the thunk alone does nothing to state; a slice still needs .addCase(thunk.pending/fulfilled/rejected, ...) wiring to actually react to it.
  4. Assuming configureStore's default middleware includes everything a large app needs — its serializability/immutability dev-mode checks are valuable but add runtime overhead; some teams explicitly narrow them for performance-sensitive production builds via the middleware option.
  5. Writing a new slice's reducers as if starting from scratch each time, duplicating async-status boilerplate (status: "idle"|"loading"|...) by hand across many slices instead of extracting a small shared helper — a common RTK-specific pattern once several slices need the same pending/fulfilled/rejected shape.

📋 Quick Reference

text
createSlice({ name, initialState, reducers, extraReducers })
  → generates BOTH action creators (slice.actions.x) and the reducer (slice.reducer)
  → reducer bodies can "mutate" the draft directly — Immer produces the real immutable update

configureStore({ reducer: { ... } })
  → replaces createStore + combineReducers + applyMiddleware(thunk) + DevTools wiring,
    all with sensible defaults out of the box

createAsyncThunk(typePrefix, asyncPayloadCreatorFn)
  → auto-generates pending/fulfilled/rejected action types around a Promise
  → wire them up in a slice's extraReducers via builder.addCase(...)

Immer mechanism: Proxy-wrapped draft records writes, produces a NEW object graph on return,
                 structurally shares every untouched part of the tree (same references)

Still plain Redux underneath: one store, dispatched actions, pure reducers — RTK only
removes the boilerplate around writing them, per Part 2's closing note in the Redux topic.

🧠 Key takeaways

  • createSlice generates action types/creators and reducers in one place.
  • Immer lets you write state.items.push(x) safely; it produces a new immutable state.
  • configureStore includes thunk, DevTools and serializability/immutability checks.
  • createAsyncThunk gives pending/fulfilled/rejected actions; RTK Query handles server-state caching.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What does Redux Toolkit improve?core▾

configureStore with good defaults (devtools, thunk, serializability checks), createSlice generating actions and reducers with Immer (mutating syntax produces immutable updates), createAsyncThunk, createEntityAdapter, and RTK Query for data fetching.

Follow-ups interviewers ask
  • Why Immer? Write simple mutating code while keeping state immutable.
Q2What is RTK Query?core▾

A data fetching and caching layer built into RTK: define endpoints, get auto-generated hooks, caching, deduplication, invalidation via tags, polling and optimistic updates.

Follow-ups interviewers ask
  • RTK Query vs TanStack Query? Similar capabilities; RTK Query integrates with the Redux store.
Q3How does createAsyncThunk work?core▾

It wraps an async function and dispatches pending, fulfilled and rejected actions automatically; slices handle them in extraReducers to update loading and error state.

Follow-ups interviewers ask
  • How do you cancel? The thunk receives an AbortSignal.
Q4What's createEntityAdapter for?senior▾

Managing normalized collections (ids + entities) with prebuilt reducers (addOne, upsertMany, removeOne) and memoized selectors.

Follow-ups interviewers ask
  • Benefit? Consistent normalized state without boilerplate.
Q5How do you type Redux Toolkit with TypeScript?core▾

Infer RootState and AppDispatch from the store, create typed hooks (useAppSelector, useAppDispatch), and type payloads with PayloadAction<T>.

Follow-ups interviewers ask
  • Why typed hooks? Avoid repeating types in every component.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Is state.items.push() inside a createSlice reducer a mutation bug?
Draft state.
2. What does createAsyncThunk dispatch automatically?
Lifecycle actions.
3. Fetching and caching server data with RTK, prefer…
Server-state tool.
4. configureStore adds by default…
Sensible defaults.