The Complete Redux Handbook (2026 Edition): Redux Toolkit, Best Practices and Common Pitfalls

Redux in 2026 means Redux Toolkit. If a tutorial starts with createStore, hand-written action type constants and a switch statement, it is teaching you the 2017 version. This handbook covers how Redux is actually written now, when you should not use it at all, and the mistakes I see most often when I'm asked to look at an existing codebase.
Updated October 2026. The original version of this guide used
createStoreand Create React App. Both are deprecated. Every example below uses Redux Toolkit 2.x, React-Redux 9.x and TypeScript.
What Redux is, and when you need it
Redux is a predictable state container: one store, state changes only through dispatched actions, and reducers that compute the next state from the previous one. That predictability buys you time-travel debugging, an action log of everything that happened, and logic you can test without rendering a single component.
It also costs you a layer of indirection. Most apps in 2026 need far less global client state than they did five years ago, because server state now lives in tools like RTK Query, TanStack Query or React Server Components.
Use Redux when:
Many unrelated components read and update the same client-side state.
State changes follow complex rules: multi-step workflows, undo/redo, collaborative editing, offline queues.
You want a full action history for debugging or auditing.
A large team needs one enforced pattern for where logic lives.
Skip Redux when:
Most of your "state" is data fetched from an API. Use a data-fetching library.
State is local to one component or one small subtree. Use
useStateoruseReducer.You only need to share a theme or the current user. Use React Context.
I compare the options in detail in Redux Toolkit vs Zustand vs Context vs TanStack Query.
The three principles (still true)
Single source of truth. Global state lives in one store.
State is read-only. The only way to change it is to dispatch an action, a plain object describing what happened.
Changes are made by pure functions. Reducers take the current state and an action and return the next state, with no side effects.
Redux Toolkit doesn't change these rules. It removes the boilerplate around them.
Setting up Redux Toolkit
npm install @reduxjs/toolkit react-redux
Create a slice
A slice holds one feature's state, its reducers and its generated action creators in one file.
// features/cart/cartSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
type CartItem = { id: string; name: string; qty: number }
type CartState = { items: CartItem[] }
const initialState: CartState = { items: [] }
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
itemAdded(state, action: PayloadAction<CartItem>) {
const existing = state.items.find((i) => i.id === action.payload.id)
if (existing) existing.qty += action.payload.qty
else state.items.push(action.payload)
},
itemRemoved(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i.id !== action.payload)
},
},
})
export const { itemAdded, itemRemoved } = cartSlice.actions
export default cartSlice.reducer
The "mutating" code is safe. createSlice runs reducers through Immer, which turns those mutations into a new immutable state object.
Create the store
// store.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from './features/cart/cartSlice'
export const makeStore = () =>
configureStore({
reducer: { cart: cartReducer },
})
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
configureStore combines your reducers, adds the thunk middleware, enables Redux DevTools, and adds development checks that catch accidental mutations and non-serializable values.
I export a makeStore function rather than a single store instance. Tests and Next.js both need a fresh store per run or per request.
Typed hooks
// hooks.ts
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
Use these everywhere instead of the untyped hooks.
Use it in a component
import { useAppDispatch, useAppSelector } from '@/lib/hooks'
import { itemRemoved } from './cartSlice'
export function CartList() {
const items = useAppSelector((state) => state.cart.items)
const dispatch = useAppDispatch()
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} × {item.qty}
<button onClick={() => dispatch(itemRemoved(item.id))}>Remove</button>
</li>
))}
</ul>
)
}
In a Next.js App Router project, the store needs a per-request provider. I cover that setup step by step in Redux Toolkit in the Next.js App Router.
Async logic: thunks and the listener middleware
createAsyncThunk
For a one-off async operation, createAsyncThunk dispatches pending, fulfilled and rejected actions for you.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
export const checkout = createAsyncThunk(
'cart/checkout',
async (cartId: string, { rejectWithValue }) => {
const res = await fetch(`/api/checkout/${cartId}`, { method: 'POST' })
if (!res.ok) return rejectWithValue(await res.text())
return (await res.json()) as { orderId: string }
}
)
const checkoutSlice = createSlice({
name: 'checkout',
initialState: { status: 'idle' as 'idle' | 'loading' | 'failed', orderId: null as string | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(checkout.pending, (state) => { state.status = 'loading' })
.addCase(checkout.fulfilled, (state, action) => {
state.status = 'idle'
state.orderId = action.payload.orderId
})
.addCase(checkout.rejected, (state) => { state.status = 'failed' })
},
})
In RTK 2.x, extraReducers only accepts this builder callback. The old object syntax was removed.
The listener middleware
For "when X happens, do Y" logic, use createListenerMiddleware. It covers most of what teams used Redux Saga for, without generators.
import { createListenerMiddleware } from '@reduxjs/toolkit'
import { itemAdded } from './features/cart/cartSlice'
export const listener = createListenerMiddleware()
listener.startListening({
actionCreator: itemAdded,
effect: async (action, api) => {
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ event: 'add_to_cart', id: action.payload.id }),
})
},
})
// in configureStore:
// middleware: (getDefault) => getDefault().prepend(listener.middleware)
Saga is still fine if you already run it. I wouldn't add it to a new project.
Server data: use RTK Query
The most common Redux mistake I see is hand-written loading flags, error flags and caching logic for every API endpoint. RTK Query ships inside Redux Toolkit and does that for you: caching, request deduplication, refetching and cache invalidation.
// services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
type Product = { id: string; name: string; price: number }
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product'],
endpoints: (build) => ({
getProducts: build.query<Product[], void>({
query: () => 'products',
providesTags: ['Product'],
}),
updateProduct: build.mutation<Product, Partial<Product> & { id: string }>({
query: ({ id, ...patch }) => ({ url: `products/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: ['Product'],
}),
}),
})
export const { useGetProductsQuery, useUpdateProductMutation } = api
Add it to the store:
configureStore({
reducer: { cart: cartReducer, [api.reducerPath]: api.reducer },
middleware: (getDefault) => getDefault().concat(api.middleware),
})
Then in a component: const { data, isLoading, error } = useGetProductsQuery(). That one line replaces a slice, a thunk and three loading flags.
Selectors and memoization
Selectors keep components from knowing the shape of your state. Memoized selectors stop expensive derivations from re-running, and stop components from re-rendering when nothing they use has changed.
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from './store'
const selectItems = (state: RootState) => state.cart.items
export const selectCartCount = createSelector([selectItems], (items) =>
items.reduce((sum, i) => sum + i.qty, 0)
)
The classic trap is a selector that returns a new array or object every time, such as useAppSelector(s => s.cart.items.filter(...)). It forces a re-render on every dispatch. I go deeper on this in Why your Redux app re-renders too much.
Normalizing state with createEntityAdapter
Store collections by ID, not as nested arrays. createEntityAdapter gives you a normalized { ids, entities } shape plus ready-made reducers and selectors.
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
type Todo = { id: string; title: string; done: boolean }
const todosAdapter = createEntityAdapter<Todo>()
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
todoAdded: todosAdapter.addOne,
todoUpdated: todosAdapter.updateOne,
todoRemoved: todosAdapter.removeOne,
},
})
export const todosSelectors = todosAdapter.getSelectors(
(state: { todos: ReturnType<typeof todosSlice.reducer> }) => state.todos
)
Updating one todo now touches one entry, not a whole nested tree.
Optimistic updates
With RTK Query, update the cache before the server responds, and roll it back if the request fails.
updateProduct: build.mutation<Product, Partial<Product> & { id: string }>({
query: ({ id, ...patch }) => ({ url: `products/${id}`, method: 'PATCH', body: patch }),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
const result = dispatch(
api.util.updateQueryData('getProducts', undefined, (draft) => {
const product = draft.find((p) => p.id === id)
if (product) Object.assign(product, patch)
})
)
try {
await queryFulfilled
} catch {
result.undo()
}
},
}),
Testing
Test Redux the way users experience it: render components with a real store and assert on what appears on screen. Test reducers directly only when they hold real logic. Mock the network with MSW, not your own thunks.
The full setup, including a renderWithProviders helper and RTK Query tests, is in Mastering Jest Day 6: Testing Redux Toolkit.
Common pitfalls
| Pitfall | What it costs you | Fix |
|---|---|---|
| Putting all API data in hand-written slices | Duplicate loading/error code, stale caches | RTK Query (or TanStack Query) |
| Putting form inputs and UI toggles in Redux | A re-render on every keystroke, a bloated store | Local state |
| Selectors that return new objects or arrays | Re-renders on every dispatch | createSelector or select primitives |
| Deeply nested state | Painful updates, accidental full-tree re-renders | createEntityAdapter |
| Side effects inside reducers | Untestable, unpredictable state | Thunks or the listener middleware |
| A single store instance shared on the server | One user's data leaking into another's request | makeStore per request |
Mixing legacy connect() code with new slices indefinitely |
Two patterns for every feature | A planned migration |
That last one is common in codebases that are 3+ years old. I wrote a phased plan for it: Migrating a legacy Redux codebase to Redux Toolkit.
Structuring a Redux app
Organize by feature, not by file type:
src/
lib/
store.ts
hooks.ts
features/
cart/
cartSlice.ts
cartSelectors.ts
CartList.tsx
cart.test.tsx
services/
api.ts
Everything about the cart lives in one folder. Deleting a feature means deleting one folder.
Glossary
Slice: one feature's state, reducers and action creators, created with
createSlice.Action: a plain object with a
type, describing something that happened.Reducer: a pure function
(state, action) => nextState.Thunk: a function dispatched to the store that can run async logic and dispatch other actions.
Selector: a function that reads or derives data from state.
RTK Query: Redux Toolkit's built-in data fetching and caching layer.
Entity adapter: a helper for storing collections in normalized
{ ids, entities }form.
Need a second pair of eyes on your Redux code?
If your app is past the tutorial stage, with slow screens, a store nobody wants to touch, or half the code still on connect(), that's the kind of codebase I review. My Architecture Audit takes 1–2 weeks and ends with written findings ranked by risk and effort, plus a remediation plan. For new builds, see Full-Stack & Next.js Development.





