The Redux story we keep retelling
Redux has a reputation for ceremony. Developers who remember hand-writing action creators, reducers, and dispatch logic in separate files understand why. But Redux Toolkit changes that story. Modern Redux Toolkit: a practical React & Next.js guide shows us that Redux without the boilerplate is not only possible—it's the default. The library handles 90% of the setup, leaving us to focus on what matters: the shape of our state and how it changes.
We're going to walk through the patterns that work in production, show you where Redux still earns its place alongside Next.js server components and data-fetching libraries, and explain when reaching for it is actually overhead.
createSlice: Actions and reducers, one expression
The centerpiece of Redux Toolkit is createSlice. It takes a slice of your state—say, a shopping cart—and generates both the reducer and the action creators automatically. No more hand-written switch statements or separate action type constants.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Item {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: Item[];
total: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0,
} as CartState,
reducers: {
add: (state, action: PayloadAction) => {
state.items.push(action.payload);
state.total += action.payload.price * action.payload.quantity;
},
remove: (state, action: PayloadAction) => {
const item = state.items.find((i) => i.id === action.payload);
if (item) {
state.total -= item.price * item.quantity;
state.items = state.items.filter((i) => i.id !== action.payload);
}
},
updateQuantity: (state, action: PayloadAction<{ id: string; quantity: number }>) => {
const item = state.items.find((i) => i.id === action.payload.id);
if (item) {
state.total += (action.payload.quantity - item.quantity) * item.price;
item.quantity = action.payload.quantity;
}
},
},
});
export const { add, remove, updateQuantity } = cartSlice.actions;
export default cartSlice.reducer;Notice the mutation-style syntax in the reducer functions. That's Immer at work behind the scenes. Redux Toolkit wraps every reducer with Immer, which lets you write code that looks imperative—state.items.push(), item.quantity = newValue—while Immer produces the immutable update for you. No manual spread operators, no nested object cloning. It catches bugs and makes the code more readable.
This single call replaces what would have been dozens of lines in a traditional Redux setup: separate action type constants, action creators, a large reducer function with a switch statement, and typed hooks. createSlice gives you all of that inferred and ready to use.
Typed hooks: useAppSelector and useAppDispatch
Type safety is where the modern Redux story gets compelling, especially in a TypeScript codebase. Redux Toolkit exports a createSlice result with types built in, but we need hooks that know about our store's shape and our actions' payloads.
Define your typed hooks once at the root of your app:
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch();
export const useAppSelector: TypedUseSelectorHook = useSelector;Now, every component that uses useAppSelector or useAppDispatch gets full type inference. TypeScript knows which actions exist, what payloads they accept, and what shape of state you'll get back:
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { add, remove, updateQuantity } from '@/store/cartSlice';
export function CartWidget() {
const dispatch = useAppDispatch();
const items = useAppSelector((state) => state.cart.items);
const total = useAppSelector((state) => state.cart.total);
const handleAdd = (item: Item) => {
dispatch(add(item)); // TypeScript ensures 'item' matches PayloadAction
};
const handleRemove = (id: string) => {
dispatch(remove(id)); // TypeScript knows this takes a string
};
return (
<div>
<ul>
{items.map((item) => (
<li>
{item.name} × {item.quantity}
dispatch(remove(item.id))}>Remove
</li>
))}
</ul>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}This eliminates the runtime errors that come from mistyped action payloads or selector mistakes. Your editor catches the problem before the code runs.
When Redux still shines: Complex workflows and debugging
Redux Toolkit remains the best tool for a specific class of problems. It excels when your application's client-side state is complex, interconnected, and changes in ways that are hard to predict:
- Multi-step workflows: A user moves through a checkout flow with multiple forms, each validating and saving a slice of state. Each step can go forward or backward, and changes in one step affect defaults in another. Redux makes this flow explicit and debuggable.
- Undo and redo: A design tool or document editor where users expect to undo and redo actions. Redux's action log and pure reducers make this straightforward. Libraries like
redux-undocan wrap your reducer to handle undo/redo with almost no additional code. - Time-travel debugging: Redux DevTools—a browser extension and middleware—shows you every action dispatched and lets you step backward through your state history. You can replay a user's session or step through a bug in slow motion. This is invaluable for investigating race conditions and complex interaction bugs.
- Deterministic state reproduction: Because Redux actions are pure and reducers are pure, you can serialize an action log, send it to a colleague, and replay the exact same sequence of state changes. Useful for debugging production issues.
In an e-commerce checkout, for example, you might have a Redux slice tracking form validation state, applied discounts, shipping options, and payment status. The UI logic branches based on combinations of these states. Redux makes it easy to test all those branches and to debug why a particular sequence of user actions led to an unexpected state.
When Redux is overkill: Server-centric applications
Modern Next.js applications often blur the line between server and client. With Server Components, we're fetching and rendering data on the server by default. Client-side Redux makes less sense in this world.
If most of your data comes from the server and changes primarily through server mutations, a data-fetching library is a better fit:
- TanStack Query (React Query): Manages server state—caching, refetching, synchronization, mutations. It's built for this problem.
- SWR: Lightweight, focused on fetch and revalidate patterns.
- Server Components + Next.js App Router: For many queries, you don't need client-side state at all. Fetch on the server, pass data as props, and use form actions for mutations.
A typical SaaS dashboard might load a user profile from the server, display a list of resources, and allow the user to create or delete resources. That's primarily server state. Redux adds overhead here. TanStack Query or Server Components solve it more naturally.
However, if you have ephemeral client-side state (UI open/closed state, form drafts, filter selections), Redux and TanStack Query often work together. Redux for client-only state, TanStack Query for server state.
Common mistakes
- Storing too much server data in Redux: If your state is mostly a cache of data from your API, use a dedicated caching library instead. Redux is not a server state cache.
- Forgetting that Immer only works in reducers: If you try to mutate state outside a
createSlicereducer (in a thunk, for example), the mutation won't produce a new state. Immer's draft is only active inside the reducer function. - Dispatching too frequently: Every dispatch triggers selectors to re-run. If you dispatch on every keystroke without debouncing, you'll cause unnecessary re-renders. Keep reducers batched and intentional.
- Creating separate slices for tightly coupled data: If two slices always change together, merge them. Separate slices when state can change independently and when you want to test or debug them separately.
- Ignoring the async middleware story: Redux Toolkit includes
createAsyncThunkfor async operations. It's not required (you can dispatch actions manually), but it handles loading, success, and error states consistently and integrates well with Redux DevTools.
Key takeaways
createSlicewith Immer removes Redux boilerplate and makes reducer logic readable as imperative code.- Typed hooks (
useAppSelector,useAppDispatch) ensure full TypeScript safety end-to-end, catching action payload and state shape mistakes at edit time. - Redux excels at complex client-side workflows, undo/redo, and time-travel debugging with Redux DevTools.
- For server-centric applications or when most state is fetched from the server, a data-fetching library or Server Components are a better match than Redux.
- Modern Redux Toolkit is a practical React & Next.js guide to building predictable, debuggable state management without the ceremony of the past.






Comments
Loading comments…