Redux Toolkit Patterns
Quick Guide: Redux Toolkit earns its place where state is complex enough to want DevTools, middleware, time-travel or normalised entities.
configureStoreandcreateSliceare the whole authoring surface; Immer makes reducers read as mutations while staying immutable. RTK 2.0 removed the object form ofextraReducersand the array forms ofmiddlewareandenhancers— all three are callbacks now — andAnyActiongave way toUnknownAction.
Detailed Resources:
- examples/core.md —
configureStore,createSlice, Immer reducers, prepare callbacks - examples/typed-hooks.md —
useAppSelector/useAppDispatchvia.withTypes() - examples/rtk-query.md —
createApi, endpoints, cache tags, invalidation - examples/entity-adapters.md — normalised collections and their selectors
- examples/async-thunks.md —
createAsyncThunkand its lifecycle actions - examples/selectors.md —
createSelectorand memoisation - examples/rtk-2-features.md —
combineSlices, inline selectors,buildCreateSlice - examples/middleware.md — custom middleware
- examples/testing.md — testing reducers, thunks and selectors
- examples/persistence.md — persisting the store across sessions
- reference.md — anti-pattern code, TypeScript recipes, RTK 2.0 migration, performance notes
Which path applies
- Client state the app owns — slices, reducers and selectors; follow examples/core.md.
- A collection of items keyed by id — an entity adapter gives O(1) lookup and the CRUD reducers; follow examples/entity-adapters.md.
- Data fetched over the network, when the store is where it should live — RTK Query owns the cache rather than a slice; follow examples/rtk-query.md.
Before writing Redux Toolkit code
Build the store with configureStore. It wires DevTools, the thunk middleware and the development-only mutation and serialisability checks, none of which createStore does.
Write reducers with createSlice. The action types and creators come from the reducer names, so a typo becomes a compile error rather than an action nothing handles.
Define useAppSelector and useAppDispatch once, in their own file. The plain hooks do not know RootState and do not know the dispatch accepts thunks; defining them alongside the store creates an import cycle.
Register the RTK Query middleware when you register its reducer. Without it the cache never populates, polling never fires and invalidation never runs — all silently, since the queries still resolve.
Compute derived values in selectors rather than storing them. A count kept in state has to be recalculated by every reducer that can change it, and the one that forgets is the bug.
Auto-detection: configureStore, createSlice, createAsyncThunk, createEntityAdapter, createSelector, createApi, PayloadAction, useSelector, useDispatch, .withTypes(), extraReducers, combineSlices, buildCreateSlice, UnknownAction, @reduxjs/toolkit
Applies to:
- Store configuration, slices, and reducers written against Immer
- Typed hooks and the
RootState/AppDispatchinference chain - Normalised entity state, memoised selectors, custom middleware
- RTK Query endpoints and cache invalidation
- Migrating from legacy Redux, or from RTK 1.x to 2.0
Handled elsewhere:
- Values a single component reads — component-local state needs no store
- Filters, search and pagination — those belong in the URL, where they survive a reload and can be shared
- Styling and rendering — a slice knows what the state is, not how it looks
One store, changed only by pure reducers, in response to actions that describe what happened. That constraint is what buys the DevTools timeline, replayable sessions and a state change you can point at.
RTK's contribution is removing the cost of it. createSlice derives the action types and creators from the reducer names, so the three-file dance of constants, creators and a switch statement collapses to one object. Immer lets a reducer read as a mutation while producing a new state, so the spread chains that made deep updates error-prone go away.
The overhead that remains is conceptual rather than syntactic: an action indirection between an event and a state change. Where state is small and flat, that indirection buys nothing and something lighter fits better.
RTK Query, a thunk, or middleware
Is it a straightforward request against an endpoint?
├─ YES → RTK Query — caching, invalidation and generated hooks come with it
└─ NO → Is it a multi-step flow that reads state as it goes?
├─ YES → createAsyncThunk — sequential calls, conditional logic, upload progress
└─ NO → Is it a side effect reacting to actions?
└─ YES → middleware — logging, analytics, persistence
Entity adapter or a plain array
Do the items have unique ids?
├─ NO → a plain object or array in the slice
└─ YES → Are they looked up or updated individually?
├─ YES → createEntityAdapter — O(1) by id, CRUD reducers, memoised selectors
└─ NO → an array in the slice is simpler and reads in order for free
Core patterns
Pattern 1: Store Configuration
The store's own type is the source for RootState and AppDispatch, so nothing is typed by hand.
export const store = configureStore({
reducer: { todos: todosReducer, [apiSlice.reducerPath]: apiSlice.reducer },
middleware: (getDefault) => getDefault().concat(apiSlice.middleware),
});
setupListeners(store.dispatch); // refetch on focus and reconnect
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Full code: examples/core.md
Pattern 2: Slice Creation with createSlice
State, reducers and action creators in one place. The "mutations" run through Immer.
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
},
});
A prepare callback covers an action creator that has to build its payload — generating an id, stamping a time.
Full code: examples/core.md
Pattern 3: Typed Hooks
Defined once, against the store's inferred types.
// store/hooks.ts
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
.withTypes() needs React Redux 9.1 or later. These live beside the store rather than in it, or the store imports the hooks that import the store.
Full code: examples/typed-hooks.md
Pattern 4: RTK Query for Data Fetching
An API slice declares endpoints and the tags that connect a mutation to the queries it invalidates.
export const apiSlice = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Todo"],
endpoints: (build) => ({
getTodos: build.query<Todo[], void>({
query: () => "/todos",
providesTags: ["Todo"],
}),
addTodo: build.mutation<Todo, NewTodo>({
query: (body) => ({ url: "/todos", method: "POST", body }),
invalidatesTags: ["Todo"],
}),
}),
});
Full code: examples/rtk-query.md
Pattern 5: Entity Adapters
Normalised storage — an ids array and an entities map — plus the reducers and selectors that go with it.
const usersAdapter = createEntityAdapter<User, string>({
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState(),
reducers: {
userAdded: usersAdapter.addOne,
userUpdated: usersAdapter.updateOne,
},
});
Full code: examples/entity-adapters.md
Pattern 6: Async Thunks
createAsyncThunk dispatches pending, fulfilled and rejected itself; the slice handles them in extraReducers.
const fetchUser = createAsyncThunk(
"users/fetch",
async (id: string, { rejectWithValue }) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return rejectWithValue("Not found");
return res.json();
},
);
rejectWithValue is what makes the failure payload typed instead of a serialised error.
Full code: examples/async-thunks.md
Pattern 7: Selectors and Memoisation
A selector deriving a new array or object memoises, or every render sees a new reference.
const selectActiveTodos = createSelector(
[(state: RootState) => state.todos.items],
(items) => items.filter((t) => !t.completed),
);
Full code: examples/selectors.md
Pattern 8: Middleware
The middleware option is a callback so the defaults are kept and extended rather than replaced.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(analyticsMiddleware);
Full code: examples/middleware.md
Red flags
Breaks at runtime:
- An API slice's reducer registered without its middleware — queries resolve but nothing caches, invalidates or polls, and no error says so.
setupListenersnever called —refetchOnFocusandrefetchOnReconnectare configured and inert.- Object syntax in
extraReducers— removed in RTK 2.0; the builder callback is the only form. - An array passed to
middlewareorenhancers— both take a callback in RTK 2.0. - State mutated outside a reducer — Immer's draft only exists inside
createSliceandcreateReducer. The same syntax in a thunk mutates the real state object. - Typed hooks defined in the store file — a circular import between the store and the hooks that need its types.
- A persisted store that does not exclude the RTK Query cache — rehydration restores a cache the server has moved past.
Surprising behaviour:
updateOneandupdateManymerge shallowly, so achangesobject naming a nested field replaces the whole nested object and drops its siblings.- RTK Query tag names are compared exactly:
"User"and"user"are two tags, and a mutation invalidating one leaves the other's queries alone. getDefaultMiddlewareis a function to call, not a value to spread; the callback receives it and the result is what gets concatenated.createAsyncThunkdispatches its own lifecycle actions — dispatchingpendingby hand runs the handler twice.- A selector returning
items.filter(...)unmemoised returns a new array every call, so a connected component re-renders on every action. RootStateis inferred fromstore.getState, so a slice whose state type is loose quietly loosens the type every selector is checked against.- RTK 2.0 replaced
AnyActionwithUnknownAction, which does not let you readaction.typeuntilisAction()has narrowed it — deliberately, since middleware receives anything.