Plain Redux requires a lot of boilerplate: action type constants, action creator functions, hand-written immutable updates in reducers, and manual middleware wiring. Every new feature duplicates this ceremony, leading to sprawling files and easy mistakes (e.g., accidentally mutating state).
Redux Toolkit (RTK) eliminates this with three design decisions: it
bundles Immer so you write mutating-looking reducer code that is
actually immutable under the hood; it provides createSlice which
auto-generates action creators and action types from a reducer map; and
configureStore sets up Redux DevTools and redux-thunk middleware
automatically.
// Plain Redux — three files of ceremony for one counter
const INCREMENT = 'counter/increment'
const increment = () => ({ type: INCREMENT })
function reducer(state = { value: 0 }, action) {
switch (action.type) {
case INCREMENT: return { ...state, value: state.value + 1 } // manual spread
default: return state
}
}
// RTK — everything above in ~5 lines
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment(state) { state.value++ } // Immer handles immutability
}
})
Rule of thumb: If you're writing ...state spreads or a types.js
constants file, you should be using Redux Toolkit instead.
configureStore is RTK's store factory. It accepts a reducer map
(each key becomes a top-level slice of state), and it automatically
enables the Redux DevTools Extension and adds redux-thunk
middleware — no manual compose or applyMiddleware needed.
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './features/counter/counterSlice'
import userReducer from './features/user/userSlice'
export const store = configureStore({
reducer: {
counter: counterReducer, // state.counter
user: userReducer, // state.user
},
// middleware and devTools are auto-configured; override only when needed
})
// Infer RootState and AppDispatch types (TypeScript)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Wrap your app in <Provider store={store}> from react-redux so every
component can access the store via hooks.
Rule of thumb: One configureStore call per app; export RootState
and AppDispatch from the same file so TypeScript consumers stay in sync.
createSlice takes a name, an initial state, and a reducers object.
It returns a slice object containing the generated reducer function and
an actions object whose keys match the reducer names.
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter', // prefix for action types: 'counter/increment'
initialState: { value: 0 },
reducers: {
increment(state) { state.value++ }, // action: counterSlice.actions.increment
decrement(state) { state.value-- },
incrementBy(state, action) {
state.value += action.payload // payload is the argument passed to the action creator
},
},
})
export const { increment, decrement, incrementBy } = counterSlice.actions
export default counterSlice.reducer // plug into configureStore
The action type strings are auto-namespaced as "sliceName/reducerName",
which keeps them unique across a large app without a constants file.
Rule of thumb: One createSlice per feature; export named action
creators and the default reducer from the same file.
RTK's reducers run inside Immer's produce function. Immer gives you
a draft proxy of the current state. Any mutations you apply to the
draft are recorded and used to create a new immutable state object —
your original state is never changed.
reducers: {
addTodo(state, action) {
state.items.push(action.payload) // looks like mutation, but Immer produces a new array
},
removeTodo(state, action) {
const index = state.items.findIndex(t => t.id === action.payload)
if (index !== -1) state.items.splice(index, 1) // splice on the draft is safe
},
updateTodo(state, action) {
const todo = state.items.find(t => t.id === action.payload.id)
if (todo) todo.text = action.payload.text // direct property assignment is fine
},
}
The one rule: you must either mutate the draft OR return a new
value — never both in the same reducer case. Returning undefined
implicitly keeps the current draft.
Rule of thumb: Mutate the draft for nested updates; return a replacement value only when you want to replace the entire state slice.
useSelector reads a value from the Redux store. It accepts a
selector function that receives the full RootState and returns the
value you need. The component re-renders only when that value changes.
useDispatch returns the store's dispatch function so you can send
actions.
import { useSelector, useDispatch } from 'react-redux'
import { increment, incrementBy } from './counterSlice'
function Counter() {
const count = useSelector(state => state.counter.value) // subscribe to one slice
const dispatch = useDispatch()
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(incrementBy(5))}>+5</button>
</div>
)
}
In TypeScript, use typed wrappers (useAppSelector, useAppDispatch)
derived from RootState and AppDispatch so you get full autocomplete
without repeating type annotations in every component.
Rule of thumb: Keep selector functions simple in the component; move
complex derived logic to standalone selector functions or createSelector.
createAsyncThunk wraps an async function and automatically dispatches
three lifecycle actions: pending, fulfilled, and rejected. You handle
these in extraReducers to update loading/error/data state.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
// First arg: action type prefix; second arg: async payload creator
export const fetchUser = createAsyncThunk('user/fetchById', async (userId) => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('Not found') // throw to trigger rejected
return res.json() // returned value becomes action.payload in fulfilled
})
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle', error: null },
reducers: {},
extraReducers(builder) {
builder
.addCase(fetchUser.pending, (state) => { state.status = 'loading' })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded'
state.data = action.payload // Immer draft mutation
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
},
})
Dispatch it like any action: dispatch(fetchUser(42)). The thunk returns
a promise you can await and .unwrap() to re-throw errors into a
try/catch in the component.
Rule of thumb: Use createAsyncThunk for one-off fetches; switch to
RTK Query when you need caching, deduplication, or automatic refetching.
The convention is to keep a status field ('idle' | 'loading' | 'succeeded' | 'failed') alongside an error field in the slice's
initial state. Each lifecycle case updates these fields atomically with
the data.
const initialState = {
items: [],
status: 'idle', // drives skeleton/spinner UI
error: null, // drives error banner UI
}
extraReducers(builder) {
builder
.addCase(fetchItems.pending, (state) => {
state.status = 'loading'
state.error = null // clear previous error on retry
})
.addCase(fetchItems.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchItems.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown error'
})
}
In the component, read status with useSelector and branch:
if (status === 'loading') return <Spinner />. Reset status to 'idle'
when navigating away so the next mount triggers a fresh fetch.
Rule of thumb: Never use a boolean isLoading flag — a string status
field handles all four states without impossible combinations.
RTK Query is a data-fetching and caching layer built into RTK. It
generates hooks, manages a normalized request cache, handles
deduplication, and supports automatic background refetching — things you
would have to build manually with createAsyncThunk.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const postsApi = createApi({
reducerPath: 'postsApi', // key in the Redux state
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts', // appended to baseUrl
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
}),
})
// Auto-generated hooks follow the pattern: use<EndpointName>Query
export const { useGetPostsQuery, useGetPostByIdQuery } = postsApi
Add postsApi.reducer to configureStore and
postsApi.middleware to the middleware chain. The hooks return
{ data, isLoading, isError } — no manual extraReducers needed.
Rule of thumb: Default to RTK Query for server data; use
createAsyncThunk only for non-idempotent mutations that don't fit a
REST endpoint pattern.
RTK Query uses a tag system to express which queries are invalidated when a mutation runs. A query provides tags; a mutation invalidates those tags, causing every matching query to refetch.
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'], // register tag names
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'], // this cache entry is tagged 'Post'
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }], // fine-grained tag
}),
createPost: builder.mutation({
query: (body) => ({ url: '/posts', method: 'POST', body }),
invalidatesTags: ['Post'], // bust all 'Post' queries on success
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({ url: `/posts/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }], // only bust this post
}),
}),
})
When updatePost succeeds, only the getPostById query for that specific
id refetches, leaving other cached posts untouched.
Rule of thumb: Use list tags (e.g., 'Post') to bust the whole
collection; use entity tags ({ type: 'Post', id }) to bust individual
items — mix both in createPost to refresh the list and the detail.
The auto-generated useGetXQuery hooks subscribe the component to the
cached data. They return a result object with data, isLoading,
isFetching, isError, and error properties.
import { useGetPostsQuery } from './postsApi'
function PostsList() {
const {
data: posts = [], // default to [] to avoid null checks on first render
isLoading,
isError,
error,
refetch, // manually trigger a refetch
} = useGetPostsQuery()
if (isLoading) return <p>Loading…</p>
if (isError) return <p>Error: {error.message}</p>
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}
For mutations, use the auto-generated useXMutation hook which returns
a [trigger, result] tuple — call trigger(args) to fire the request.
Rule of thumb: Prefer isLoading (true only on first fetch) over
isFetching (true on every fetch including background refetches) when
rendering the initial skeleton.
Redux DevTools is a browser extension that configureStore enables
automatically in development (no extra config needed). It connects to the
store and records every action dispatched along with a before/after state
snapshot.
// configureStore enables DevTools automatically; disable in prod if needed
export const store = configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production', // explicit control
})
With the extension open you can: time-travel (jump to any past state), replay the action log after a hot reload, inspect the diff between states, and dispatch actions manually from the DevTools panel to test reducers without touching the UI.
RTK also serializes the createAsyncThunk lifecycle actions so you see
user/fetchById/pending, user/fetchById/fulfilled, etc., in the log.
Rule of thumb: Keep action payloads serializable (plain objects, no class instances, no functions) so DevTools can display and replay them accurately.
createEntityAdapter provides a standardized way to store a
collection of items by their ID. It creates an { ids: [], entities: {} }
normalized shape and generates CRUD reducer helpers (addOne, upsertMany,
removeOne, etc.).
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
const todosAdapter = createEntityAdapter()
// { selectAll, selectById, selectIds, selectEntities, selectTotal }
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState({ status: 'idle' }), // adds ids/entities keys
reducers: {
todoAdded: todosAdapter.addOne, // pre-built reducer
todosLoaded: todosAdapter.setAll,
todoRemoved: todosAdapter.removeOne,
},
extraReducers(builder) {
builder.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload) // bulk replace
})
},
})
// Selectors are pre-built and accept RootState
export const { selectAll: selectAllTodos, selectById: selectTodoById } =
todosAdapter.getSelectors(state => state.todos)
Normalize when you have a large list and need O(1) lookups by ID (e.g., selecting one item without scanning an array).
Rule of thumb: Use createEntityAdapter whenever you fetch a list
that you later update by ID; skip it for small static lists.
createSelector (re-exported from reselect by RTK) takes one or
more input selectors and a result function. The result is
recomputed only when an input selector's output changes, preventing
unnecessary re-renders from derived computations.
import { createSelector } from '@reduxjs/toolkit'
const selectTodos = state => state.todos.items // input selector
const selectFilter = state => state.todos.filter // input selector
// result function only runs when selectTodos or selectFilter changes
export const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
if (filter === 'all') return todos
return todos.filter(t => t.status === filter) // expensive work cached
}
)
Use it inside useSelector: const todos = useSelector(selectFilteredTodos).
Because the selector reference is stable across renders (the memoized
instance is defined outside the component), React-Redux's equality check
works correctly.
Rule of thumb: Extract a createSelector call whenever the selector
computes a new array or object reference — otherwise every call returns a
new reference and causes a re-render even when the data hasn't changed.
configureStore adds redux-thunk by default. To add custom middleware,
use the middleware callback option which receives getDefaultMiddleware
so you can keep the defaults and append your own.
import { configureStore } from '@reduxjs/toolkit'
const loggerMiddleware = store => next => action => {
console.log('dispatching', action) // called before reducer
const result = next(action) // pass action down the chain
console.log('next state', store.getState())
return result
}
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware() // keeps thunk + serializability checks
.concat(loggerMiddleware), // append custom middleware
})
Do not replace getDefaultMiddleware() with an empty array unless you
intentionally want to remove the serialization warnings and thunk support —
those defaults catch common bugs.
Rule of thumb: Always call getDefaultMiddleware() as the base;
prepend for middleware that needs to run before thunks, concat for
everything else.
The feature folder (or "ducks") pattern co-locates everything related
to one domain: the slice, thunks, selectors, and component files. This
keeps related code together and avoids cross-cutting files like
allReducers.js.
src/
features/
cart/
cartSlice.ts // createSlice + extraReducers
cartThunks.ts // createAsyncThunk (or inline in slice)
cartSelectors.ts // createSelector calls
CartPage.tsx // component that uses the slice
cartApi.ts // RTK Query createApi (if feature-scoped)
user/
userSlice.ts
userSelectors.ts
ProfilePage.tsx
store/
store.ts // configureStore — imports slice reducers
hooks.ts // typed useAppSelector / useAppDispatch
// store/hooks.ts — typed wrappers to avoid repeating types in every component
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Rule of thumb: Keep each feature self-contained; the store.ts file
should only import reducers — no business logic lives there.
Redux shines for shared, cross-component global state with complex update logic. It is overkill — and adds friction — for state that fits simpler tools.
Avoid Redux for:
- Local UI state (modal open, form input value, hover) —
useStateis sufficient and keeps the logic next to the component. - Server cache state (fetched lists, single items) — RTK Query or React Query handle caching, deduplication, and refetching better than a hand-rolled async slice.
- Small apps with one team — Context +
useReducermay be all you need; the cognitive overhead of slices/selectors/actions is not always worth it.
// BAD — Redux for toggle state no other component needs
dispatch(setModalOpen(true))
// GOOD — local state for isolated UI
const [isOpen, setIsOpen] = useState(false)
Symptoms of unnecessary Redux: your slices hold isModalOpen flags,
you dispatch actions from a single component and no other component
subscribes, or you have more action types than actual data fields.
Rule of thumb: If no other component needs to read or write a piece
of state, keep it local with useState — move it to Redux only when the
need actually arises.
By default, action creators generated by createSlice accept one
argument which becomes action.payload. The prepare callback lets
you transform the arguments — generate IDs, add timestamps, or restructure
the payload — before the reducer runs.
import { createSlice, nanoid } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded: {
reducer(state, action) {
state.push(action.payload) // payload already shaped
},
prepare(text) { // called first with the caller's args
return {
payload: {
id: nanoid(), // auto-generate a unique id
text,
createdAt: new Date().toISOString(), // timestamp at dispatch time
completed: false,
},
}
},
},
},
})
// Caller just passes the text — ID and timestamp are added automatically
dispatch(todoAdded('Buy milk'))
Rule of thumb: Use prepare to keep side effects (ID generation,
timestamps) out of reducers and out of components — reducers must be
pure, and components shouldn't be responsible for shaping payloads.
Mutations are defined with builder.mutation and exposed as
useXMutation hooks. The hook returns a [triggerFn, resultObject]
tuple. Call the trigger to fire the request; the result object tracks
the mutation's lifecycle.
import { useCreatePostMutation } from './postsApi'
function NewPostForm() {
const [createPost, { isLoading, isError, isSuccess }] = useCreatePostMutation()
const [title, setTitle] = useState('')
async function handleSubmit(e) {
e.preventDefault()
try {
await createPost({ title }).unwrap() // unwrap re-throws on error
setTitle('') // clear form on success
} catch (err) {
console.error('Failed to save post', err)
}
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button disabled={isLoading}>
{isLoading ? 'Saving…' : 'Save'}
</button>
{isSuccess && <p>Saved!</p>}
{isError && <p>Something went wrong.</p>}
</form>
)
}
If the mutation's invalidatesTags overlaps with a query's providesTags,
that query automatically refetches — no manual cache update needed.
Rule of thumb: Always call .unwrap() in a try/catch so thrown errors
surface in your component rather than being silently swallowed.
RTK automatically names action types as "sliceName/reducerName" for
slice actions, and "actionPrefix/pending|fulfilled|rejected" for
createAsyncThunk lifecycle actions.
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment(state) { state.value++ },
},
})
counterSlice.actions.increment.type // 'counter/increment'
counterSlice.actions.increment() // { type: 'counter/increment' }
const fetchUser = createAsyncThunk('user/fetch', async (id) => { /* ... */ })
fetchUser.pending.type // 'user/fetch/pending'
fetchUser.fulfilled.type // 'user/fetch/fulfilled'
fetchUser.rejected.type // 'user/fetch/rejected'
Because names are derived from the slice name, keep slice names
descriptive and scoped to their feature. Avoid generic names like
'data' or 'state' which will produce cryptic action logs in DevTools.
Rule of thumb: The DevTools action log is your first debugging tool — treat action type strings as documentation and name them clearly.
More State Management interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.