Redux Toolkit: Modern Redux Development
Complete guide to Redux Toolkit — createSlice, createAsyncThunk, RTK Query, and migration from traditional Redux
Introduction
Redux Toolkit (RTK) is the official, opinionated toolset for efficient Redux development. It simplifies store setup, reduces boilerplate, and provides powerful utilities like createSlice and RTK Query. If you're still writing switch-case reducers and action type constants, it's time to modernize.
Why Redux Toolkit?
Traditional Redux vs Redux Toolkit
// ❌ Traditional Redux - verbose boilerplate
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
function addTodo(text) {
return { type: ADD_TODO, payload: { text } };
}
function toggleTodo(index) {
return { type: TOGGLE_TODO, payload: { index } };
}
function todosReducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, { text: action.payload.text, done: false }];
case TOGGLE_TODO:
return state.map((todo, i) =>
i === action.payload.index ? { ...todo, done: !todo.done } : todo
);
default:
return state;
}
}
// ✅ Redux Toolkit - clean and concise
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo(state, action) {
state.push({ text: action.payload, done: false });
},
toggleTodo(state, action) {
const todo = state[action.payload];
if (todo) todo.done = !todo.done;
}
}
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
Core Concepts
createSlice
The heart of Redux Toolkit. It automatically generates action creators and action types:
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
reset() {
return { value: 0 }; // Direct return also works
}
}
});
// Generated action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Generated reducer
export default counterSlice.reducer;
// Action types are auto-generated
console.log(increment.type); // "counter/increment"
Key Points:
- Mutate state directly thanks to Immer integration
- Action types are
"sliceName/reducerName" - Action creators accept a single argument as
payload - Return a new state object to replace entirely
configureStore
Simplifies store setup with good defaults:
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import todosReducer from './todosSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer
},
// Middleware and DevTools are configured automatically
});
// TypeScript: infer types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Automatic features:
- ✅ Redux DevTools Extension enabled
- ✅
redux-thunkmiddleware included - ✅ Development checks for mutations and serializability
- ✅ Combine reducers automatically
createAsyncThunk
Handles async logic without writing thunks manually:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
// Create async thunk
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
);
// Handle in slice
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = 'pending';
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = 'succeeded';
state.entities[action.payload.id] = action.payload;
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.error.message;
});
}
});
Lifecycle Actions:
pending— Request startedfulfilled— Request succeededrejected— Request failed
RTK Query
Built-in data fetching and caching solution:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
// Query: fetch data
getUsers: builder.query({
query: () => '/users',
providesTags: ['User']
}),
getUserById: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }]
}),
// Mutation: modify data
createUser: builder.mutation({
query: (user) => ({
url: '/users',
method: 'POST',
body: user
}),
invalidatesTags: ['User']
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }]
})
})
});
// Auto-generated hooks
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useCreateUserMutation,
useUpdateUserMutation
} = api;
RTK Query Benefits:
- ✅ Automatic caching and cache invalidation
- ✅ Loading/error states built-in
- ✅ Automatic re-fetching on focus/reconnect
- ✅ Optimistic updates support
- ✅ Code generation for hooks
Migration Guide
Step 1: Replace switch-case with createSlice
// Before: Traditional reducer
const initialState = { items: [], filter: 'all' };
function todoReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_TODO':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_TODO':
return { ...state, items: state.items.filter(t => t.id !== action.payload) };
case 'SET_FILTER':
return { ...state, filter: action.payload };
default:
return state;
}
}
// After: createSlice
const todoSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
addTodo(state, action) {
state.items.push(action.payload);
},
removeTodo(state, action) {
state.items = state.items.filter(t => t.id !== action.payload);
},
setFilter(state, action) {
state.filter = action.payload;
}
}
});
Step 2: Replace action type enums with PayloadAction
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
done: boolean;
}
interface TodosState {
items: Todo[];
filter: 'all' | 'active' | 'done';
}
const initialState: TodosState = {
items: [],
filter: 'all'
};
const todoSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo(state, action: PayloadAction<Todo>) {
state.items.push(action.payload);
},
removeTodo(state, action: PayloadAction<string>) {
state.items = state.items.filter(t => t.id !== action.payload);
},
setFilter(state, action: PayloadAction<TodosState['filter']>) {
state.filter = action.payload;
}
}
});
Step 3: Update tests
// Before: Testing traditional reducer
it('should add a todo', () => {
const action = { type: 'ADD_TODO', payload: { id: '1', text: 'Test' } };
const newState = todoReducer([], action);
expect(newState).toHaveLength(1);
});
// After: Testing slice reducer
import todoReducer, { addTodo } from './todoSlice';
it('should add a todo', () => {
const newState = todoReducer([], addTodo({ id: '1', text: 'Test' }));
expect(newState.items).toHaveLength(1);
});
TypeScript Patterns
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;
Matchers in extraReducers
const slice = createSlice({
name: 'data',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
// Match a single action
.addCase(someAction, (state, action) => {})
// Match multiple actions
.addMatcher(
(action) => action.type.endsWith('/pending'),
(state) => { state.loading = true; }
)
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.loading = false;
state.error = action.error.message;
}
)
// Default case
.addDefaultCase((state) => {});
}
});
Best Practices
Do's
// ✅ Mutate state in reducers (Immer handles immutability)
state.items.push(newItem);
state.user.name = 'New Name';
delete state.tempData;
// ✅ Use prepare callback for complex action creation
reducers: {
addTodo: {
reducer(state, action) {
state.push(action.payload);
},
prepare(text) {
return {
payload: {
id: nanoid(),
text,
createdAt: new Date().toISOString()
}
};
}
}
}
// ✅ Use RTK Query for API calls
const { data, isLoading, error } = useGetUsersQuery();
// ✅ Normalize state for collections
{
entities: {
'1': { id: '1', name: 'Alice' },
'2': { id: '2', name: 'Bob' }
},
ids: ['1', '2']
}
Don'ts
// ❌ Don't spread state in createSlice reducers
state = { ...state, value: newValue }; // Wrong!
// ❌ Don't use switch-case with createSlice
// createSlice handles this for you
// ❌ Don't call APIs in reducers
// Use createAsyncThunk or RTK Query instead
// ❌ Don't put non-serializable values in state
// No functions, promises, symbols, class instances
// ❌ Don't mutate state outside of createSlice
// Always dispatch actions
Common Patterns
Optimistic Updates with RTK Query
updateTodo: builder.mutation({
query: (todo) => ({
url: `/todos/${todo.id}`,
method: 'PATCH',
body: todo
}),
// Optimistic update
async onQueryStarted(todo, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getTodos', undefined, (draft) => {
const existing = draft.find(t => t.id === todo.id);
if (existing) Object.assign(existing, todo);
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo(); // Rollback on failure
}
}
})
Selectors with createSelector
import { createSelector } from '@reduxjs/toolkit';
const selectTodos = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;
export const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
}
);
Summary Table
| Feature | Traditional Redux | Redux Toolkit |
|---|---|---|
| Reducer | switch-case | createSlice |
| Actions | Manual creators | Auto-generated |
| Store | createStore | configureStore |
| Async | redux-thunk | createAsyncThunk |
| Data fetching | Manual | RTK Query |
| Immutability | Spread operators | Immer (automatic) |
| DevTools | Manual setup | Automatic |
| TypeScript | Complex types | Built-in support |
Conclusion
Redux Toolkit is the modern way to write Redux:
createSliceeliminates reducer boilerplateconfigureStoreprovides sensible defaultscreateAsyncThunksimplifies async logic- RTK Query handles data fetching and caching
- Immer integration lets you write mutable-style updates
If you're starting a new Redux project or maintaining an existing one, Redux Toolkit is the recommended approach. The migration is straightforward and the benefits are immediate.
#redux #redux-toolkit #react #state-management #typescript #web-development