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
createSliceconfig. - Main difference:
createSlice+ Immer lets you writestate.value += 1directly instead of returning{ ...state, value: state.value + 1 }. configureStoreships with Redux Thunk and DevTools enabled by default.createAsyncThunkauto-generatespending/fulfilled/rejectedaction 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
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
createSliceincrementally, replacecreateStorewithconfigureStore. - Non-React projects: RTK core utilities work in plain JS and Node.js.
Comparison table
| Feature | Vanilla Redux | Redux Toolkit |
|---|---|---|
| Reducer setup | Manual switch + spread operators | createSlice + Immer mutations |
| Store config | createStore + manual middleware | configureStore (Thunk + DevTools by default) |
| Async logic | Manual thunk boilerplate | createAsyncThunk (auto pending/fulfilled/rejected) |
| Entity management | Manual normalization code | createEntityAdapter with built-in CRUD ops |
| Action creators | Manually written | Auto-generated from createSlice |
| Bundle size | ~2KB core | ~10KB gzipped (includes Immer + Thunk) |
| When to use | Legacy code, custom middleware experiments | All 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
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
// 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
// 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
// Wrong
configureStore({ reducer: {} }); // Store exists but holds nothingPass 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:
createEntityAdapterfor 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
createStorewithconfigureStorefirst, 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.
// 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.
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 lifecycleAfter 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.
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 readyA concise answer to help you respond confidently on this topic during an interview.