skip to content

Redux Toolkit

RTK is the modern default: configureStore, createSlice, createAsyncThunk, and Immer so mutating-looking code still produces immutable updates. Interviewers ask what boilerplate it removes and what it is doing underneath.

part ofReduxoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In Redux Toolkit, what does createSlice generate when you convert a hand-written todos reducer into a slice?

level: juniorimportance: must knowfreq 76%

answer

  1. one call, three hand-written parts gone
  2. name plus case reducer name
  3. actions object and reducer
  4. extraReducers takes a builder callback

basics

~20 s

createSlice takes a name, an initialState and a reducers object and returns the slice reducer plus one action creator per case reducer, with each type generated as name/caseName, replacing hand-written constants, action creators and the switch.

solid answer

~40 s

You call `createSlice({ name: 'todos', initialState, reducers: { todoAdded(state, action) {...}, todoToggled(...) {...} } })`. It returns a slice object whose `reducer` handles those cases and whose `actions` holds one generated action creator per case reducer. Each action type is built as `` `${name}/${caseName}` ``, so `todoAdded` dispatches `'todos/todoAdded'`. The action type constants, the hand-written action creators and the `switch` all disappear, and because the case reducers run through Immer you can write `state.push(action.payload)` instead of spreading arrays. Actions the slice did not define itself, such as another slice's action or a thunk's lifecycle actions, go in `extraReducers`, which in RTK 2 only accepts a builder callback.

code

ts · 27 lines
ts
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit'

interface Todo { id: string; text: string; completed: boolean }

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    todoAdded: {
      reducer(state, action: PayloadAction<Todo>) {
        state.push(action.payload)
      },
      prepare(text: string) {
        return { payload: { id: nanoid(), text, completed: false } }
      },
    },
    todoToggled(state, action: PayloadAction<string>) {
      const todo = state.find((t) => t.id === action.payload)
      if (todo) todo.completed = !todo.completed
    },
  },
})

export const { todoAdded, todoToggled } = todosSlice.actions
export default todosSlice.reducer

// todoToggled('42') -> { type: 'todos/todoToggled', payload: '42' }

go deeper

for a junior

Recall the three inputs, name, initialState and reducers, and the two outputs you export, slice.actions and slice.reducer. Know that a generated type looks like todos/todoAdded.

for a middle

Explain the difference between reducers, which define and handle an action, and extraReducers, which only handle actions defined elsewhere, and show the prepare callback for payload shaping.

for a senior

Show judgment about slice boundaries: which actions a slice owns, how cross-slice reactions go through the builder, and why keeping ids and timestamps in prepare keeps reducers replayable.

for a principal

Frame createSlice as generated code over the unchanged Redux contract, so a migration can convert one reducer at a time while hand-written reducers keep working in the same store.

## The hand-written starting point A classic Redux todos feature is spread over three pieces that must agree with each other: - **action type constants** such as `const TODO_ADDED = 'todos/todoAdded'`; - **action creators** such as `todoAdded(todo)` returning `{ type: TODO_ADDED, payload: todo }`; - a **reducer** with a `switch (action.type)` whose every case copies the state immutably, for example `return [...state, action.payload]` or a `map` that rebuilds one todo with `completed` flipped. Each piece is simple, but the three are connected only by string matching. Renaming a case means touching three places, and a typo in a constant produces a reducer that silently ignores the action. ## What createSlice takes `createSlice` from `@reduxjs/toolkit` replaces all three with one call. Its main options are: 1. `name` — a string used as the prefix of every generated action type. 2. `initialState` — the slice's starting value (it can also be a lazy initializer function). 3. `reducers` — an object whose keys are case names and whose values are **case reducers**: functions `(state, action) => newState | void` that handle exactly one action. 4. `extraReducers` — optional, for actions the slice did **not** define itself. For object or array state, each case reducer receives an Immer draft, so the reducer can be written as if it mutated: `state.push(action.payload)` or `todo.completed = !todo.completed`. Immer turns those writes into a new immutable state; it is still immutable Redux underneath. ## What it returns | Field on the slice | What it is | |---|---| | `reducer` | the combined case reducer for this slice, ready to pass to `configureStore` | | `actions` | one action creator per key in `reducers` | | `caseReducers` | the original case reducer functions, handy for tests or reuse | | `name` / `reducerPath` | the slice name and the key it expects in the root state | | `getInitialState()` | returns the initial state value | Every generated action creator returns a plain action object, `{ type, payload }`, where the type is built as `` `${name}/${caseName}` ``. With `name: 'todos'` and a case named `todoAdded`, the type is `'todos/todoAdded'`. Calling the action creator only **builds** the action; you still `dispatch` it. Each creator also carries a `type` property and a `match(action)` type guard, so other code can recognise the action without importing a string constant. If an action needs its payload prepared before it reaches the reducer — for example generating an id with RTK's `nanoid` — you write the case as an object `{ reducer, prepare }`, and `prepare` returns `{ payload }` (optionally with `meta` and `error`). ## Responding to actions the slice did not define `reducers` both **defines** an action and **handles** it. When the slice must react to an action defined elsewhere — another slice's `userLoggedOut`, or the `pending`/`fulfilled`/`rejected` actions of a `createAsyncThunk` — it goes in `extraReducers`, which generates no action creators. - In **RTK 2**, `extraReducers` must be a **builder callback**: `extraReducers: (builder) => { builder.addCase(userLoggedOut, () => initialState) }`. - The older object form keyed by action type was removed; passing an object throws in development. - `builder.addCase` calls must come before any `builder.addMatcher`, and `builder.addDefaultCase` comes last. ## Where the slice plugs in The slice's `reducer` is mounted under a key in the store, for example `configureStore({ reducer: { todos: todosSlice.reducer } })`. That key should match the slice's `reducerPath`, which defaults to `name`. An RTK 2 slice can also declare a `selectors` field whose functions receive the slice state; `slice.selectors` exposes them wrapped to read from `rootState[reducerPath]`. Components then dispatch `todoAdded('Buy milk')` and read the list through a selector, exactly as they did before the conversion: nothing downstream needs to know that the reducer and its action creators were generated. ## What the conversion actually removed - The type constants, because types are derived from `name` and the case names. - The action creators, because `slice.actions` generates them. - The `switch`, because `createSlice` builds a lookup from type to case reducer. - Most spread-and-copy code, because Immer lets case reducers express updates as writes. What it did **not** remove is the Redux model itself: there is still one store, actions are still plain serialisable objects, reducers are still pure functions of `(state, action)`, and components still dispatch and select. `createSlice` is a code generator on top of that model, which is why a slice's reducer can be mounted next to a hand-written reducer in the same store. A good interview answer names the three things that disappear, gives the `name/caseName` rule for the generated type, and says where foreign actions go.

  • How do you make a Redux Toolkit slice reset itself when another slice's logout action is dispatched?
    Handle it in `extraReducers` with the builder: `builder.addCase(userLoggedOut, () => initialState)`. Returning a value replaces the slice state. Putting the case in `reducers` would not work, because a key there defines a new action type, `todos/userLoggedOut`, instead of listening to the existing one.
  • A Redux Toolkit case reducer needs a generated id and a timestamp. Where should that code live?
    In a `prepare` callback, by writing the case as `{ reducer, prepare }`. `prepare` receives the arguments passed to the action creator and returns `{ payload }`, so randomness stays out of the reducer and the reducer remains pure and replayable.
  • How does other code check whether an action came from a Redux Toolkit slice without importing type strings?
    Every generated action creator carries a `type` property and a `match(action)` type guard, so `todoAdded.match(action)` both tests the type and narrows the payload type in TypeScript. Builder `addCase` accepts the action creator directly for the same reason.

saying these in an interview costs you the question

  • Calling a generated action creator dispatches the action to the store
  • You still write action type constants next to a slice
  • The generated type is just the case name, such as todoAdded
  • extraReducers can still be an object keyed by action type in RTK 2
  • Listening to another slice's action means adding its name under reducers
open as a page

What does Redux Toolkit's configureStore set up by default that a bare Redux 5 createStore call leaves out?

level: middleimportance: must knowfreq 62%

basics

~10 s

configureStore combines a reducer map automatically, installs the thunk middleware, adds development-only checks for state mutation, non-serializable values and dispatched action creators, and connects the Redux DevTools extension, all without hand-written enhancer composition.

open as a page

In Redux Toolkit, which actions does one createAsyncThunk call dispatch, and how does rejectWithValue change the rejected action?

level: middleimportance: must knowfreq 63%

basics

~20 s

A createAsyncThunk dispatches typePrefix/pending before the payload creator runs, then typePrefix/fulfilled with the resolved value or typePrefix/rejected on failure. rejectWithValue puts your chosen value in the rejected action's payload instead of losing it to error serialisation.

open as a page

In a Redux Toolkit case reducer, why may you either mutate state or return a new value, but never both?

level: middleimportance: must knowfreq 64%

basics

~20 s

Redux Toolkit runs case reducers through Immer, which treats either the recorded draft mutations or the returned value as the next state. Mutating the draft and also returning a different value is ambiguous, so Immer throws.

open as a page

In Redux Toolkit's createEntityAdapter, how do addOne, setOne, upsertOne and updateOne differ when an entity with that id already exists?

level: middleimportance: should knowfreq 36%

basics

~20 s

For an existing id, addOne does nothing, setOne replaces the whole entity, upsertOne shallow-merges the new fields into it, and updateOne shallow-merges an { id, changes } object. For a missing id, updateOne is ignored while the other three insert the entity.

open as a page

In Redux Toolkit, how do you stop a slow, earlier createAsyncThunk response from overwriting a newer one when a detail page fetches on every route change?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Record meta.requestId from the pending action and ignore fulfilled or rejected actions whose requestId is not the latest, and abort the superseded call from the effect cleanup with promise.abort(), passing thunkAPI.signal to fetch so the request itself is cancelled.

open as a page