Redux Toolkit: Modern State Management for React
Redux has been the industry standard for managing complex application state, but traditional Redux requires extensive boilerplate: action creators, action types, reducer functions, and middleware configuration. Redux Toolkit eliminates this friction by providing opinionated APIs that handle the common cases, allowing you to write less code while maintaining Redux's powerful debugging and time-travel capabilities. This guide teaches you to build production-grade Redux applications with Redux Toolkit.
Table of Contents
- Why Redux Toolkit?
- Installation and Setup
- Creating Slices with createSlice
- Configuring the Store
- Selecting State with useSelector
- Dispatching Actions with useDispatch
- Handling Async Operations
- Organizing Multiple Slices
- Performance Optimization
- Testing Redux Code
- Common Pitfalls
- FAQ
Why Redux Toolkit?
The Redux Problem
Traditional Redux requires writing significant boilerplate for every feature:
// ❌ Traditional Redux: too much code
// 1. Define action types
const INCREASE_COUNTER = 'INCREASE_COUNTER';
const DECREASE_COUNTER = 'DECREASE_COUNTER';
// 2. Define action creators
export const increaseCounter = () => ({ type: INCREASE_COUNTER });
export const decreaseCounter = () => ({ type: DECREASE_COUNTER });
// 3. Define reducer
export function counterReducer(state = 0, action) {
switch (action.type) {
case INCREASE_COUNTER:
return state + 1;
case DECREASE_COUNTER:
return state - 1;
default:
return state;
}
}
This verbosity becomes painful with large apps. Redux Toolkit solves this by providing utilities that generate boilerplate automatically.
The Redux Toolkit Solution
// ✅ Redux Toolkit: minimal code, same power
import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increaseCounter: (state) => state + 1,
decreaseCounter: (state) => state - 1,
},
});
export const { increaseCounter, decreaseCounter } = counterSlice.actions;
export default counterSlice.reducer;
Same functionality, 70% less code. Redux Toolkit handles action creation, action types, and reducer logic internally.
Installation and Setup
Install Dependencies
npm install @reduxjs/toolkit react-redux
# or yarn
yarn add @reduxjs/toolkit react-redux
Redux Toolkit includes:
- @reduxjs/toolkit: Core utilities (createSlice, configureStore, createAsyncThunk)
- react-redux: React bindings (useSelector, useDispatch, Provider)
Project Structure
src/
├── store/
│ ├── store.ts # Configure store
│ ├── hooks.ts # Custom typed hooks
│ └── slices/
│ ├── counterSlice.ts
│ ├── userSlice.ts
│ └── todoSlice.ts
├── App.tsx
└── index.tsx
Creating Slices with createSlice
Basic Slice Structure
A slice combines the reducer, actions, and initial state for a single feature:
TypeScript Version
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
status: 'idle' | 'loading';
}
const initialState: CounterState = {
value: 0,
status: 'idle',
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// Synchronous actions
increment: (state) => {
// Immer library lets us "mutate" state immutably
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
reset: (state) => {
state.value = 0;
},
setStatus: (state, action: PayloadAction<'idle' | 'loading'>) => {
state.status = action.payload;
},
},
});
// Export actions (auto-generated by createSlice)
export const { increment, decrement, incrementByAmount, reset, setStatus } =
counterSlice.actions;
// Export reducer (default export convention)
export default counterSlice.reducer;
JavaScript Version
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
value: 0,
status: 'idle',
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
reset: (state) => {
state.value = 0;
},
setStatus: (state, action) => {
state.status = action.payload;
},
},
});
export const { increment, decrement, incrementByAmount, reset, setStatus } =
counterSlice.actions;
export default counterSlice.reducer;
Key Insight: Immer Middleware
Redux Toolkit uses Immer under the hood. This means you can write "mutative" code in reducers, and Immer automatically converts it to immutable updates:
// This looks like mutation but is immutable
reducers: {
increment: (state) => {
state.value += 1; // Safe! Immer handles the immutability
},
}
// Equivalent to without Immer:
// return { ...state, value: state.value + 1 }
Configuring the Store
Creating the Store
Redux Toolkit's configureStore combines multiple slices and configures middleware automatically:
TypeScript Version
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import todoReducer from './slices/todoSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
todos: todoReducer,
},
});
// Infer RootState and AppDispatch types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
JavaScript Version
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
import todoReducer from './slices/todoSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
todos: todoReducer,
},
});
Providing the Store to React
Wrap your app with the Redux Provider:
import { Provider } from 'react-redux';
import { store } from './store/store';
import App from './App';
export default function Root() {
return (
<Provider store={store}>
<App />
</Provider>
);
}
Selecting State with useSelector
Basic Selection
Select slices of state using useSelector:
TypeScript Version
import { useSelector } from 'react-redux';
import type { RootState } from './store/store';
export function Counter() {
// Select the entire counter slice
const counter = useSelector((state: RootState) => state.counter);
return (
<div>
<p>Count: {counter.value}</p>
<p>Status: {counter.status}</p>
</div>
);
}
JavaScript Version
import { useSelector } from 'react-redux';
export function Counter() {
const counter = useSelector((state) => state.counter);
return (
<div>
<p>Count: {counter.value}</p>
<p>Status: {counter.status}</p>
</div>
);
}
Creating Typed Hooks
To avoid repeatedly typing RootState, create pre-typed hooks:
TypeScript Version
// store/hooks.ts
import { useSelector, useDispatch, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Pre-typed useSelector and useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
Now use these hooks in components:
// ✅ Much cleaner
import { useAppSelector, useAppDispatch } from './store/hooks';
export function Counter() {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
Memoized Selectors with Reselect
For expensive calculations, memoize selectors with reselect:
npm install reselect
import { createSelector } from 'reselect';
import type { RootState } from './store';
// Selector function
const selectTodos = (state: RootState) => state.todos.items;
// Memoized selector - only recalculates when todos change
export const selectCompletedTodos = createSelector(
[selectTodos],
(todos) => todos.filter((todo) => todo.completed)
);
export const selectCompletedCount = createSelector(
[selectCompletedTodos],
(completed) => completed.length
);
// In component
function TodoStats() {
const completed = useAppSelector(selectCompletedCount);
// Component only re-renders if completed count actually changes
return <p>Completed: {completed}</p>;
}
Dispatching Actions with useDispatch
Basic Dispatch
import { useAppDispatch } from './store/hooks';
import { increment, incrementByAmount } from './store/slices/counterSlice';
export function Counter() {
const dispatch = useAppDispatch();
return (
<div>
<button onClick={() => dispatch(increment())}>
Increment
</button>
<button onClick={() => dispatch(incrementByAmount(5))}>
Add 5
</button>
</div>
);
}
Handling Action Payloads
Actions can carry data via payloads:
TypeScript Version
interface User {
id: string;
name: string;
email: string;
}
const userSlice = createSlice({
name: 'user',
initialState: null as User | null,
reducers: {
setUser: (state, action: PayloadAction<User>) => {
return action.payload;
},
updateUserEmail: (state, action: PayloadAction<string>) => {
if (state) {
state.email = action.payload;
}
},
},
});
// Dispatch with payload
const handleLogin = (user: User) => {
dispatch(setUser(user));
};
JavaScript Version
const userSlice = createSlice({
name: 'user',
initialState: null,
reducers: {
setUser: (state, action) => {
return action.payload;
},
updateUserEmail: (state, action) => {
if (state) {
state.email = action.payload;
}
},
},
});
const handleLogin = (user) => {
dispatch(setUser(user));
};
Handling Async Operations
createAsyncThunk
For async operations (API calls), use createAsyncThunk:
TypeScript Version
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
user: null,
loading: false,
error: null,
};
// Define the async thunk
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId: string, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return await response.json() as User;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : 'Unknown error'
);
}
}
);
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
clearUser: (state) => {
state.user = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;
JavaScript Version
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return await response.json();
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : 'Unknown error'
);
}
}
);
const initialState = {
user: null,
loading: false,
error: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
clearUser: (state) => {
state.user = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;
Using Async Thunks in Components
import { useAppDispatch, useAppSelector } from './store/hooks';
import { fetchUser } from './store/slices/userSlice';
export function UserProfile({ userId }: { userId: string }) {
const dispatch = useAppDispatch();
const { user, loading, error } = useAppSelector((state) => state.user);
useEffect(() => {
dispatch(fetchUser(userId));
}, [userId, dispatch]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>ID: {user.id}</p>
</div>
);
}
Organizing Multiple Slices
Slice Organization Pattern
// store/slices/index.ts
export { default as counterReducer } from './counterSlice';
export { default as userReducer } from './userSlice';
export { default as todoReducer } from './todoSlice';
export * from './counterSlice';
export * from './userSlice';
export * from './todoSlice';
Clean Store Configuration
// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import {
counterReducer,
userReducer,
todoReducer,
} from './slices';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
todos: todoReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Performance Optimization
Selector Memoization
Prevent unnecessary re-renders by memoizing selectors:
// ❌ PROBLEM: Creates new object every render
const selectCounter = (state: RootState) => ({
value: state.counter.value,
status: state.counter.status,
});
// ✅ SOLUTION: Memoize with createSelector
import { createSelector } from 'reselect';
const selectCounterValue = (state: RootState) => state.counter.value;
const selectCounterStatus = (state: RootState) => state.counter.status;
export const selectCounter = createSelector(
[selectCounterValue, selectCounterStatus],
(value, status) => ({ value, status })
);
Entity Adapters for Normalized State
For managing collections, use createEntityAdapter:
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
interface Todo {
id: string;
title: string;
completed: boolean;
}
const todoAdapter = createEntityAdapter<Todo>();
const todoSlice = createSlice({
name: 'todos',
initialState: todoAdapter.getInitialState(),
reducers: {
addTodo: todoAdapter.addOne,
updateTodo: todoAdapter.updateOne,
removeTodo: todoAdapter.removeOne,
},
});
// Entity adapter provides optimized selectors
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds,
} = todoAdapter.getSelectors((state: RootState) => state.todos);
Testing Redux Code
Testing Reducers
import counterReducer, { increment, decrement } from './counterSlice';
describe('counterSlice', () => {
it('should handle increment', () => {
const initialState = { value: 0, status: 'idle' };
const result = counterReducer(
initialState,
increment()
);
expect(result.value).toBe(1);
});
it('should handle decrement', () => {
const initialState = { value: 5, status: 'idle' };
const result = counterReducer(
initialState,
decrement()
);
expect(result.value).toBe(4);
});
});
Testing Async Thunks
import { fetchUser } from './userSlice';
import { configureStore } from '@reduxjs/toolkit';
describe('fetchUser', () => {
it('should fetch user successfully', async () => {
const store = configureStore({
reducer: { user: userReducer },
});
await store.dispatch(fetchUser('123'));
const state = store.getState();
expect(state.user.loading).toBe(false);
expect(state.user.user).toBeDefined();
});
});
Testing Selectors
import { selectCompletedTodos } from './todoSlice';
it('should select completed todos', () => {
const state: RootState = {
todos: {
items: [
{ id: '1', title: 'Learn Redux', completed: true },
{ id: '2', title: 'Build app', completed: false },
],
},
};
const completed = selectCompletedTodos(state);
expect(completed).toHaveLength(1);
});
Common Pitfalls
Pitfall 1: Mutating State Outside of Reducers
// ❌ WRONG: Mutating outside reducer
const user = useAppSelector((state) => state.user);
user.name = 'New Name'; // Mutating returned state!
// ✅ CORRECT: Dispatch action to update
dispatch(updateUserName('New Name'));
Pitfall 2: Forgetting Immer in Custom Objects
// ❌ PROBLEM: Returns undefined
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
// Forgot to return!
}
}
// ✅ CORRECT: Immer handles mutation internally
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
// No return needed - state is updated
}
}
Pitfall 3: Creating New Objects in Selectors
// ❌ PROBLEM: Component re-renders on every state change
const selectUserInfo = (state: RootState) => ({
name: state.user.name,
email: state.user.email,
});
// ✅ SOLUTION: Use createSelector
import { createSelector } from 'reselect';
export const selectUserInfo = createSelector(
[(state: RootState) => state.user.name, (state: RootState) => state.user.email],
(name, email) => ({ name, email })
);
FAQ
Q: When should I use Redux Toolkit vs Context API?
A: Use Redux Toolkit for large apps with complex state, many actions, and debugging needs. Use Context API for simpler state (theme, auth, notifications). Redux Toolkit excels with complex state and middleware.
Q: Can I use Redux Toolkit with TypeScript?
A: Yes, Redux Toolkit has excellent TypeScript support. Create pre-typed hooks using TypedUseSelectorHook and useDispatch<AppDispatch>().
Q: How do I handle errors in async thunks?
A: Use rejectWithValue to return custom error data:
export const fetchData = createAsyncThunk(
'data/fetch',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/data');
if (!response.ok) {
return rejectWithValue({ status: response.status });
}
return response.json();
} catch (error) {
return rejectWithValue({ error: 'Network error' });
}
}
);
Q: How do I debug Redux state?
A: Redux DevTools extension is configured by default in Redux Toolkit. Install the browser extension and use the time-travel debugger to inspect every action and state change.
Q: Can slices reference other slices?
A: Yes, in extraReducers:
const featureSlice = createSlice({
name: 'feature',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(someOtherAction, (state, action) => {
// Handle actions from other slices
});
},
});
Q: What's the difference between reducers and extraReducers?
A: reducers define actions created by the slice. extraReducers handle actions from other slices or async thunks. This keeps the slice focused on its own domain.
Key Takeaway: Redux Toolkit eliminates Redux boilerplate while maintaining its power for complex state management. Combine createSlice for feature state, createAsyncThunk for API calls, and createSelector for performance. Use pre-typed hooks to maximize TypeScript safety.
Questions? How are you managing complex state? Share your Redux Toolkit patterns in the comments.
Google AdSense Placeholder
CONTENT SLOT