# 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 `createStore` and 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 `useState` or `useReducer`.
    
*   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](https://blog.aakibshah.com.np/redux-vs-zustand-vs-context-vs-tanstack-query).

## The three principles (still true)

1.  **Single source of truth.** Global state lives in one store.
    
2.  **State is read-only.** The only way to change it is to dispatch an action, a plain object describing what happened.
    
3.  **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

```bash
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.

```ts
// 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

```ts
// 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

```ts
// 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

```tsx
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](https://blog.aakibshah.com.np/redux-toolkit-nextjs-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.

```ts
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.

```ts
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.

```ts
// 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:

```ts
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.

```ts
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](https://blog.aakibshah.com.np/why-your-redux-app-re-renders).

## 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.

```ts
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.

```ts
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](https://blog.aakibshah.com.np/mastering-jest-day-6-testing-redux-with-jest-69ba205c257f).

## 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](https://blog.aakibshah.com.np/migrate-legacy-redux-to-redux-toolkit).

## Structuring a Redux app

Organize by feature, not by file type:

```text
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](https://aakibshah.com.np/services) 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](https://aakibshah.com.np/services/nextjs-development).

[Book a free consultation →](https://calendly.com/skywalker_03/one-on-one)

## Read next

*   [Redux Toolkit in the Next.js App Router](https://blog.aakibshah.com.np/redux-toolkit-nextjs-app-router)
    
*   [Redux Toolkit vs Zustand vs Context vs TanStack Query](https://blog.aakibshah.com.np/redux-vs-zustand-vs-context-vs-tanstack-query)
    
*   [Why your Redux app re-renders too much](https://blog.aakibshah.com.np/why-your-redux-app-re-renders)
    
*   [Migrating a legacy Redux codebase to Redux Toolkit](https://blog.aakibshah.com.np/migrate-legacy-redux-to-redux-toolkit)
    
*   [Mastering Jest Day 6: Testing Redux Toolkit](https://blog.aakibshah.com.np/mastering-jest-day-6-testing-redux-with-jest-69ba205c257f)
    
*   [7 signs your React state management is slowing your team down](https://blog.aakibshah.com.np/react-state-management-warning-signs)
    
*   [Do you still need Redux for your SaaS MVP?](https://blog.aakibshah.com.np/do-you-need-redux-for-saas-mvp)
