Redux Toolkit: Redux Without The Pain
Master Redux Toolkit (RTK) - the official, recommended way to write Redux. Learn createSlice for reducing boilerplate, createAsyncThunk for async operations, configureStore setup, Immer for immutable updates, and RTK Query for data fetching. Complete migration guide from vanilla Redux.
The Boilerplate Breaking Point
After months with Redux, I had a love-hate relationship with it. I loved:
- Predictable state updates
- DevTools time-travel
- Clear data flow
I hated:
- Seven files for a feature
- Three action types for every async operation
- Switch statements that went on forever
- The constant spreading to maintain immutability
Then I discovered Redux Toolkit (RTK)—the official, opinionated toolset for Redux. The React team and Redux maintainers built it together. It’s not a different library; it’s Redux with the rough edges sanded off.
The Same Counter, Transformed
Remember the counter from last time? Action types file, action creators file, reducer file, store configuration…
Here’s the same thing with Redux Toolkit:
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => {
state.count += 1; // "Mutating" is fine! (Immer handles it)
},
decrement: (state) => {
state.count -= 1;
},
reset: (state) => {
state.count = 0;
},
incrementByAmount: (state, action) => {
state.count += action.payload;
},
},
});
export const { increment, decrement, reset, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
That’s it. One file. Actions and reducer together. No action type constants. No switch statements.
And look at this:
state.count += 1;
I’m mutating state! In Redux! This should be illegal!
But it’s not. Redux Toolkit uses Immer under the hood. You write “mutating” code, and Immer produces immutable updates. It’s syntactic sugar that makes Redux actually pleasant to write.
Store Configuration Made Simple
Old Redux store setup:
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
);
Redux Toolkit:
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
import userReducer from './features/user/userSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
},
});
configureStore automatically:
- Combines reducers
- Adds Redux Thunk middleware
- Enables Redux DevTools
- Adds development checks for common mistakes
Async Actions with createAsyncThunk
Remember the triple-action pattern for async operations? START, SUCCESS, ERROR? createAsyncThunk generates all three:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// This creates three action types automatically:
// - users/fetchUser/pending
// - users/fetchUser/fulfilled
// - users/fetchUser/rejected
export const fetchUser = createAsyncThunk(
'users/fetchUser',
async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
const userSlice = createSlice({
name: 'user',
initialState: {
data: null,
loading: false,
error: null,
},
reducers: {
// Regular synchronous reducers here
clearUser: (state) => {
state.data = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;
Using it in a component:
import { useDispatch, useSelector } from 'react-redux';
import { fetchUser } from './userSlice';
function UserProfile({ userId }) {
const dispatch = useDispatch();
const { data: user, loading, error } = useSelector(state => state.user);
useEffect(() => {
dispatch(fetchUser(userId));
}, [dispatch, userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <Profile user={user} />;
}
Still some boilerplate for the loading/error states, but way better than manually creating six action types and three action creators.
Error Handling and Payload Customization
createAsyncThunk handles errors, but you can customize the behavior:
export const fetchUser = createAsyncThunk(
'users/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
// Return a custom error payload
return rejectWithValue({
status: response.status,
message: 'User not found',
});
}
return response.json();
} catch (error) {
return rejectWithValue({
message: error.message,
});
}
}
);
// In the slice
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload; // Now contains our custom error object
});
Prepare Callbacks for Complex Actions
Sometimes you need to transform the payload before it hits the reducer:
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: {
reducer: (state, action) => {
state.push(action.payload);
},
prepare: (text) => ({
payload: {
id: nanoid(), // Generate ID here, not in reducer
text,
completed: false,
createdAt: new Date().toISOString(),
},
}),
},
},
});
// Usage
dispatch(addTodo('Buy milk'));
// Payload becomes: { id: 'abc123', text: 'Buy milk', completed: false, createdAt: '...' }
The prepare callback keeps reducers pure while handling ID generation and timestamps.
Entity Adapter: CRUD Made Easy
If you’re managing collections of items (users, posts, products), createEntityAdapter is magic:
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const postsAdapter = createEntityAdapter({
// Sort by date, newest first
sortComparer: (a, b) => b.date.localeCompare(a.date),
});
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState({
loading: false,
error: null,
}),
reducers: {
// These are auto-generated CRUD operations!
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsReceived: postsAdapter.setAll,
},
});
// Auto-generated selectors
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds,
} = postsAdapter.getSelectors(state => state.posts);
The adapter stores entities in a normalized format automatically:
{
ids: ['post1', 'post2', 'post3'],
entities: {
'post1': { id: 'post1', title: 'First Post', ... },
'post2': { id: 'post2', title: 'Second Post', ... },
'post3': { id: 'post3', title: 'Third Post', ... },
},
loading: false,
error: null,
}
Normalized state with zero effort.
TypeScript Support
Redux Toolkit has excellent TypeScript support. Here’s a typed slice:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
// Typed hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Full type inference. Autocomplete everywhere. Compile-time errors for wrong payloads.
Migration Strategy
I didn’t rewrite everything at once. Here’s how I migrated gradually:
Step 1: Replace createStore with configureStore
// Before
const store = createStore(rootReducer, applyMiddleware(thunk));
// After
const store = configureStore({ reducer: rootReducer });
This alone gives you DevTools and better defaults.
Step 2: Convert One Slice at a Time
// Old reducer file can coexist with new slices
const store = configureStore({
reducer: {
// New RTK slice
counter: counterSlice.reducer,
// Old reducer - still works!
user: oldUserReducer,
cart: oldCartReducer,
},
});
Step 3: Convert Async Actions to createAsyncThunk
Start with the most-used async actions. The old thunks still work while you migrate.
Step 4: Add Entity Adapters for Collections
Convert list/detail views last. They benefit most from entity adapters.
RTK Query: The Next Level
Redux Toolkit also includes RTK Query—a powerful data fetching and caching solution:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUser: builder.query({
query: (userId) => `users/${userId}`,
}),
getPosts: builder.query({
query: () => 'posts',
}),
addPost: builder.mutation({
query: (newPost) => ({
url: 'posts',
method: 'POST',
body: newPost,
}),
}),
}),
});
export const { useGetUserQuery, useGetPostsQuery, useAddPostMutation } = api;
Using it:
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useGetUserQuery(userId);
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <Profile user={user} />;
}
Automatic caching, deduplication, background refetching, optimistic updates… It’s like React Query, but integrated with Redux.
My Current Setup
After migrating to RTK, my Redux setup looks like this:
src/
├── app/
│ ├── store.js
│ └── hooks.js # Typed useAppDispatch, useAppSelector
├── features/
│ ├── auth/
│ │ └── authSlice.js
│ ├── cart/
│ │ └── cartSlice.js
│ └── posts/
│ └── postsSlice.js
└── services/
└── api.js # RTK Query API definition
Clean. Organized. Minimal boilerplate.
What I Wish I’d Known Earlier
-
Start with Redux Toolkit, not vanilla Redux. The old tutorials teaching raw Redux first are outdated. RTK is the standard now.
-
Immer is not magic—learn how it works. Understanding that Immer uses Proxies helps debug weird issues.
-
Don’t fight the patterns.
createSlicewants you to colocate actions and reducers. Embrace it. -
Entity adapters are underrated. Any time you have a list of items by ID, reach for
createEntityAdapter. -
RTK Query might be all you need. For most apps, RTK Query eliminates the need for manual async thunks entirely.
-
The Redux style guide exists. Read it. It codifies best practices.
When to Use Redux Toolkit vs Alternatives
After using RTK extensively, here’s my decision tree:
Use Redux Toolkit when:
- You need global state with complex interactions
- DevTools and time-travel debugging are valuable
- Your team already knows Redux
- You want one tool for state AND data fetching (RTK Query)
Consider alternatives when:
- State is simple (use Context + useReducer)
- You only need data fetching (use React Query or SWR)
- You want less boilerplate and more flexibility (Zustand)
- You’re building a small app (might not need global state at all)
The Journey Continues
Redux Toolkit transformed Redux from “necessary evil” to “genuinely pleasant.” The patterns are solid. The tooling is mature. The developer experience is excellent.
But I kept hearing about alternatives. Zustand. Jotai. Recoil. Teams abandoning Redux entirely. Were they onto something?
Time to explore the post-Redux world.
P.S. — I timed myself rewriting a feature from vanilla Redux to Redux Toolkit. Old version: 4 files, 180 lines. New version: 1 file, 45 lines. Same functionality. The best code is the code you don’t have to write.
Saurav Sitaula
Software Architect • Nepal