Redux Toolkit: When and Why
The Redux Paradox
Redux has a reputation problem. Ask a junior developer about Redux and they'll say "too much boilerplate." Ask a senior engineer at a Fortune 500 company and they'll say "it's the backbone of our application." Both are right — and that tells you everything about when Redux belongs in your architecture.
Here's the honest take: Redux is not the default choice anymore. For most React apps in 2025+, TanStack Query handles server state and Zustand or Jotai handles client state. That covers 90% of use cases with less code. But there's a remaining 10% where Redux isn't just appropriate — it's the best tool available.
Redux is a predictable state container with strict rules: state is read-only, changes happen through pure functions (reducers), and every state change is recorded as an action. Think of it like a bank ledger: every transaction is recorded, nothing changes without an entry, and you can audit the entire history. This overhead is pointless for a sidebar toggle but invaluable for financial dashboards, complex workflows, and systems that need audit trails.
Redux Toolkit: The Modern Redux
Redux Toolkit (RTK) is the official, opinionated way to write Redux. It wraps the core Redux library and eliminates the boilerplate that gave Redux its bad name.
createSlice
A slice is a piece of Redux state with its reducers and actions auto-generated:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
discount: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], discount: 0 } as CartState,
reducers: {
addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
applyDiscount: (state, action: PayloadAction<number>) => {
state.discount = action.payload;
},
},
});
export const { addItem, removeItem, applyDiscount } = cartSlice.actions;
export const cartReducer = cartSlice.reducer;
Notice something? You're mutating state.items.push(...) directly. RTK uses Immer under the hood, so these "mutations" produce immutable updates. You get the ergonomics of mutable code with the safety of immutability.
createAsyncThunk
For async operations that aren't server-state-cached (so TanStack Query isn't the fit), createAsyncThunk manages the pending/fulfilled/rejected lifecycle:
import { createAsyncThunk } from '@reduxjs/toolkit';
const processPayment = createAsyncThunk(
'checkout/processPayment',
async (paymentData: PaymentData, { rejectWithValue }) => {
try {
const result = await paymentAPI.charge(paymentData);
return result;
} catch (error) {
return rejectWithValue((error as Error).message);
}
}
);
const checkoutSlice = createSlice({
name: 'checkout',
initialState: {
status: 'idle' as 'idle' | 'processing' | 'succeeded' | 'failed',
error: null as string | null,
transactionId: null as string | null,
},
reducers: {
resetCheckout: (state) => {
state.status = 'idle';
state.error = null;
state.transactionId = null;
},
},
extraReducers: (builder) => {
builder
.addCase(processPayment.pending, (state) => {
state.status = 'processing';
state.error = null;
})
.addCase(processPayment.fulfilled, (state, action) => {
state.status = 'succeeded';
state.transactionId = action.payload.transactionId;
})
.addCase(processPayment.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
RTK Query: The Built-In Data Fetcher
RTK Query is Redux Toolkit's answer to TanStack Query — a data fetching and caching layer built on top of Redux:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product', 'User'],
endpoints: (builder) => ({
getProducts: builder.query<Product[], { category?: string }>({
query: ({ category }) => category ? `/products?category=${category}` : '/products',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Product' as const, id })), 'Product']
: ['Product'],
}),
addProduct: builder.mutation<Product, NewProduct>({
query: (product) => ({
url: '/products',
method: 'POST',
body: product,
}),
invalidatesTags: ['Product'],
}),
}),
});
export const { useGetProductsQuery, useAddProductMutation } = apiSlice;
RTK Query auto-generates hooks, handles caching, deduplication, and cache invalidation via a tag-based system. If you're already using Redux for other reasons, RTK Query keeps everything in one ecosystem.
Middleware: The Extension Point
Redux's middleware system is its secret weapon for complex use cases. Every action passes through middleware before reaching the reducer:
dispatch(action) → middleware1 → middleware2 → ... → reducer → new state
Listener Middleware (recommended over saga)
The listener middleware is RTK's modern replacement for Redux Saga. It handles side effects with a simpler API:
import { createListenerMiddleware } from '@reduxjs/toolkit';
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: addItem,
effect: async (action, listenerApi) => {
const state = listenerApi.getState() as RootState;
const itemCount = state.cart.items.length;
if (itemCount === 1) {
listenerApi.dispatch(showToast('First item added! 🎉'));
}
await analyticsAPI.trackAddToCart(action.payload);
},
});
When to Use Each Middleware
| Middleware | Best For | Complexity |
|---|---|---|
| Thunk (built-in) | Simple async operations, one-off API calls | Low |
| Listener | Reacting to actions, coordinating side effects, replacing most saga patterns | Medium |
| Saga (redux-saga) | Complex orchestration: race conditions, cancellation, channels, long-running processes | High |
| Custom | Logging, analytics, crash reporting, audit trails | Varies |
When Redux Still Makes Sense
Here's the honest checklist. Redux is the right choice when you need:
1. Time-Travel Debugging
Redux DevTools let you step forward and backward through every state change. For complex state machines (checkout flows, multi-step wizards, trading dashboards), this is invaluable for debugging.
2. Action Audit Trail
Every state change is a recorded action with a type and payload. In finance, healthcare, or compliance-heavy apps, this action log serves as an audit trail: "who changed what, when, and why."
3. Middleware Pipelines
Complex side-effect orchestration — "when the user does X, also do Y and Z, unless condition W is true" — is naturally expressed as middleware. Redux Saga or the listener middleware handle this elegantly.
4. Predictable State Machines
When your state has well-defined transitions and illegal states should be impossible, Redux reducers enforce this at the type level. (Though XState is even better for this specific case.)
5. Large Team Coordination
Redux's strict patterns (actions, reducers, selectors) create a shared language across a large team. Every developer knows exactly where to find state logic and how to modify it.
The Evolution: Redux to RTK to RTK Query
Understanding the evolution helps you appreciate why RTK exists:
When Redux Is Overkill
| What developers do | What they should do |
|---|---|
| Using Redux for a simple app because 'it might get complex later' Premature architecture is a tax on every feature you build. Redux adds ceremony (store setup, Provider, slices, selectors) that simple apps don't need. YAGNI. | Start with useState + TanStack Query. Add Zustand if shared state emerges. Add Redux only if you need middleware, time-travel, or audit trails. |
| Using createAsyncThunk for all API calls instead of TanStack Query createAsyncThunk gives you pending/fulfilled/rejected states, but you still manage caching, deduplication, refetching, and garbage collection yourself. TanStack Query does all of that automatically. | Use TanStack Query for data fetching. Use createAsyncThunk only for async operations that need Redux middleware integration. |
| Using Redux Saga for simple async operations like API calls Saga's generator-based API has a steep learning curve and significant bundle cost. The listener middleware covers 90% of side-effect patterns with a simpler API. | Use the listener middleware for most side effects. Use saga only for complex orchestration (cancellation, race conditions, channels). |
- 1Redux Toolkit is the ONLY way to write Redux. Vanilla Redux with manual action types is a legacy pattern.
- 2Use Redux when you need: time-travel debugging, action audit trails, complex middleware pipelines, or large-team coordination patterns.
- 3RTK Query is valuable when you're already using Redux and want server + client state in one DevTools timeline.
- 4createSlice + Immer gives you mutable syntax with immutable guarantees. Don't fight it — embrace the mutable style.
- 5Listener middleware replaces Redux Saga for 90% of side-effect patterns. Reach for saga only for complex orchestration.
- 6For most apps in 2025+, TanStack Query + Zustand is simpler, lighter, and sufficient. Redux is a specific tool for specific problems.