Skip to main content

Redux toolkit

Redux Toolkit (RTK) is the official toolset from Redux maintainers that cuts boilerplate by roughly 80% through Immer-powered reducers, auto-generated action creators, and a pre-configured store setup.

Theory

TL;DR

  • Vanilla Redux is writing every action type, switch case, and spread operator by hand. RTK generates all of that from a single createSlice config.
  • Main difference: createSlice + Immer lets you write state.value += 1 directly instead of returning { ...state, value: state.value + 1 }.
  • configureStore ships with Redux Thunk and DevTools enabled by default.
  • createAsyncThunk auto-generates pending/fulfilled/rejected action types for any async operation.
  • Decision rule: use RTK for all new Redux apps. Vanilla Redux only makes sense on legacy codebases that cannot migrate.

Quick example

javascript
import { createSlice, configureStore } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; }, // Immer handles immutability decrement: (state) => { state.value -= 1; }, incrementByAmount: (state, action) => { state.value += action.payload; } } }); export const { increment, decrement, incrementByAmount } = counterSlice.actions; const store = configureStore({ reducer: { counter: counterSlice.reducer } });

That one createSlice call replaces 40+ lines of vanilla Redux: no ACTION_TYPE constants, no switch cases, no spread operators. configureStore connects Thunk middleware and Redux DevTools automatically.

Key difference from vanilla Redux

In vanilla Redux, reducers must return a new state object explicitly: { ...state, value: state.value + 1 }. RTK wraps reducers in Immer's produce function, which creates a Proxy around the state draft. You mutate the draft, Immer detects which paths changed, and produces a new immutable object through structural sharing. Only the changed parts get copied; everything else shares references. The result is identical to hand-written immutable code, but the syntax is far simpler.

When to use

  • Small app with local UI state: skip Redux entirely. Zustand or Context is enough.
  • Medium React app with shared async data: RTK with slices and createAsyncThunk.
  • Complex app with normalized entities like users and posts: RTK plus createEntityAdapter.
  • Legacy Redux codebase: wrap existing reducers in createSlice incrementally, replace createStore with configureStore.
  • Non-React projects: RTK core utilities work in plain JS and Node.js.

Comparison table

FeatureVanilla ReduxRedux Toolkit
Reducer setupManual switch + spread operatorscreateSlice + Immer mutations
Store configcreateStore + manual middlewareconfigureStore (Thunk + DevTools by default)
Async logicManual thunk boilerplatecreateAsyncThunk (auto pending/fulfilled/rejected)
Entity managementManual normalization codecreateEntityAdapter with built-in CRUD ops
Action creatorsManually writtenAuto-generated from createSlice
Bundle size~2KB core~10KB gzipped (includes Immer + Thunk)
When to useLegacy code, custom middleware experimentsAll new production apps (official recommendation)

How Immer works inside RTK

When createSlice runs your reducer, it wraps it in Immer's produce. Immer creates a Proxy (draft) over the current state. Any mutation you write, like state.items.push(item), gets intercepted by that Proxy and recorded. After your reducer returns, Immer walks the recorded changes and builds a new state object through structural sharing: only the changed paths get new references, unchanged paths share the original. This is why state.value += 1 inside a slice is safe, and why the same mutation outside Immer would break Redux's change detection.

configureStore composes the Redux store with applyMiddleware(thunk) and the Redux DevTools enhancer. You can still add custom middleware through the middleware option.

Async operations with createAsyncThunk

javascript
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; export const fetchTodos = createAsyncThunk('todos/fetch', async () => { const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5'); return res.json(); // Becomes action.payload in fulfilled }); const todosSlice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle' }, reducers: { toggleTodo: (state, action) => { state.items[action.payload].completed = !state.items[action.payload].completed; } }, extraReducers: (builder) => { builder .addCase(fetchTodos.pending, (state) => { state.status = 'loading'; }) .addCase(fetchTodos.fulfilled, (state, action) => { state.items = action.payload; state.status = 'succeeded'; }) .addCase(fetchTodos.rejected, (state) => { state.status = 'failed'; }); } });

extraReducers handles action types that originate outside the slice, like a createAsyncThunk response. The reducers field auto-generates action creators. extraReducers does not. That distinction trips people up in interviews.

Common mistakes

Mutating state outside a slice

javascript
// Wrong: direct mutation in a plain reducer const reducer = (state = { list: [] }, action) => { state.list.push(action.payload); // Breaks Redux change detection return state; };

Immer only protects you inside createSlice. useSelector compares state references. If you mutate the existing object and return it, the reference does not change and components do not re-render. Fix: move logic into createSlice.

Missing extraReducers for async thunks

javascript
// Wrong: thunk exists but slice ignores it const slice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle' }, reducers: {}, extraReducers: {} // Empty - status stays 'idle' forever });

If you dispatch fetchTodos() but have no addCase(fetchTodos.fulfilled, ...), state never updates. Use the builder pattern and handle all three cases.

Empty reducer map in configureStore

javascript
// Wrong configureStore({ reducer: {} }); // Store exists but holds nothing

Pass actual slice reducers: reducer: { counter: counterSlice.reducer }.

Skipping error handling for rejected thunks

If a fetch fails and you have no .addCase(fetchTodos.rejected, ...), the app quietly stays in loading state. In production, always handle rejected with at least a status update.

Forgetting upsertOne after optimistic updates

When adding a post optimistically with a temp ID and the real server response arrives, you need postsAdapter.upsertOne(state, action.payload) to replace the temp entry. Without it you end up with both the temp and the real record in state at the same time.

Real-world usage

  • React/Next.js dashboards: separate slices for users, orders, products. Each feature gets its own slice file.
  • RTK Query: API caching in apps already on Redux. Replaces manual fetch + thunk + loading state patterns without adding a second library.
  • Normalized feeds: createEntityAdapter for Twitter-like timelines where the same user object appears in multiple places.
  • SSR with Next.js: server-side store hydration in getServerSideProps, slice state serialized as JSON and sent to the client.
  • Migration path: most teams replace createStore with configureStore first, then convert reducers to slices feature by feature.

Follow-up questions

Q: What is Immer and why does RTK use it?
A: Immer wraps your reducer in a Proxy that intercepts mutations. After the reducer runs, Immer produces a new immutable state through structural sharing. RTK uses it so you can write state.value += 1 instead of return { ...state, value: state.value + 1 }.

Q: What is the difference between reducers and extraReducers in createSlice?
A: reducers creates both the reducer logic and the action creators automatically. extraReducers adds reducer logic for actions that originate outside the slice, like thunks or actions from other slices. No action creators are generated in extraReducers.

Q: How does createAsyncThunk differ from a manually written thunk?
A: It auto-generates three action types: pending, fulfilled, and rejected. It also wraps the async function in a try/catch. A manual thunk requires you to dispatch each of those states yourself and handle errors explicitly.

Q: What is createEntityAdapter and when does it help?
A: It converts an array like [{id: 1, name: 'Alice'}] into a normalized structure { ids: [1], entities: { 1: {id: 1, name: 'Alice'} } } and provides CRUD operations like addOne, upsertOne, removeOne. It helps when the same entity appears in multiple parts of state and you need a single source of truth.

Q: (Senior) How does tag invalidation work in RTK Query?
A: Each query endpoint defines providesTags and each mutation defines invalidatesTags. When a mutation succeeds, RTK Query checks which cached queries share those tags and re-fetches them automatically. For optimistic updates, use patchQueryData to update the cache before the API responds, then undo the patch if the mutation rejects.

Examples

Counter slice: RTK vs vanilla Redux

Same outcome, drastically different code volume.

javascript
// RTK: complete working slice in ~10 lines import { createSlice } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; } } }); console.log( counterSlice.reducer(undefined, counterSlice.actions.increment()) ); // { value: 1 }

Vanilla Redux equivalent requires: a constant for the action type, a separate action creator function, a reducer with a switch/case, and a spread to avoid mutation. RTK collapses all four into the reducers field.

Todo app with async fetch

A realistic pattern: fetch from an API, track loading state, allow local toggles.

javascript
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit'; export const fetchTodos = createAsyncThunk('todos/fetch', async () => { const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5'); return res.json(); }); const todosSlice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle' }, reducers: { toggleTodo: (state, action) => { const todo = state.items[action.payload]; if (todo) todo.completed = !todo.completed; // Safe Immer mutation } }, extraReducers: (builder) => { builder .addCase(fetchTodos.pending, (state) => { state.status = 'loading'; }) .addCase(fetchTodos.fulfilled, (state, action) => { state.items = action.payload; state.status = 'succeeded'; }) .addCase(fetchTodos.rejected, (state) => { state.status = 'failed'; }); } }); const store = configureStore({ reducer: { todos: todosSlice.reducer } }); // In a React component: // const { items, status } = useSelector((state) => state.todos); // const dispatch = useDispatch(); // dispatch(fetchTodos()); // Triggers pending -> fulfilled lifecycle

After fetchTodos() resolves, status becomes 'succeeded' and items holds the fetched data. toggleTodo works without spread operators because Immer tracks the mutation automatically.

Entity adapter with optimistic updates

This pattern trips up senior candidates. The key detail is upsertOne after the API responds.

javascript
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit'; const postsAdapter = createEntityAdapter(); // Produces: { ids: [], entities: {} } export const addNewPost = createAsyncThunk('posts/add', async (postData) => { const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(postData) }); return res.json(); // Returns post with real server-assigned ID }); const postsSlice = createSlice({ name: 'posts', initialState: postsAdapter.getInitialState(), reducers: {}, extraReducers: (builder) => { builder .addCase(addNewPost.pending, (state, action) => { // Optimistic: add locally with a temp ID before API responds postsAdapter.addOne(state, { id: 'temp-' + Date.now(), ...action.meta.arg }); }) .addCase(addNewPost.fulfilled, (state, action) => { // Replace temp entry with real server response // upsertOne matches on id - without it, temp duplicates the real record postsAdapter.upsertOne(state, action.payload); }) .addCase(addNewPost.rejected, (state, action) => { // Remove the optimistic entry if the request failed const tempId = Object.keys(state.entities).find((id) => id.startsWith('temp-')); if (tempId) postsAdapter.removeOne(state, tempId); }); } }); // Selectors come for free: // postsAdapter.getSelectors((state) => state.posts).selectAll(store.getState())

The pending case adds the post immediately so the UI feels instant. The fulfilled case calls upsertOne, which finds the existing entry by id and replaces it, or inserts a new one if no match. Skipping upsertOne and using addOne instead leaves the temp entry alongside the real one.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?