⚡ Lesson 8.3: Redux Toolkit (RTK)
Welcome to modern Redux! Redux Toolkit has revolutionized how we use Redux by dramatically reducing boilerplate while maintaining all the power of Redux. If you've heard Redux is "too complex" or "too much code," RTK solves those problems. In this comprehensive lesson, you'll learn how to use Redux Toolkit to build scalable, maintainable state management for complex applications. We'll cover slices, async thunks, RTK Query, and TypeScript integration from the ground up.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand Redux core concepts and when to use Redux vs other solutions
- Install and configure Redux Toolkit in a React TypeScript project
- Create type-safe slices with reducers and actions
- Handle async operations with createAsyncThunk
- Use RTK Query for powerful data fetching and caching
- Integrate Redux DevTools for debugging
- Write properly typed Redux code with TypeScript
- Organize Redux code in a scalable way
- Apply Redux Toolkit best practices
- Compare Redux Toolkit with Zustand and Context
Estimated Time: 75-90 minutes
Prerequisites: Lessons 8.1-8.2 (State Management Overview, Zustand Basics), Modules 1-5
📑 In This Lesson
🤔 Understanding Redux and Redux Toolkit
Before diving into Redux Toolkit, let's understand what problems it solves and when you should use it.
What is Redux?
Redux is a predictable state container for JavaScript applications. It provides a centralized store for all application state with strict rules about how state can be updated.
📖 Definition
Redux is a state management library based on the Flux architecture that uses a single, immutable state tree and pure reducer functions to handle state changes. Redux Toolkit (RTK) is the official, opinionated toolset for efficient Redux development that reduces boilerplate and enforces best practices.
Core Redux Concepts
Redux follows three fundamental principles:
- Single Source of Truth: The entire state of your application is stored in a single object tree within a single store
- State is Read-Only: The only way to change state is to dispatch an action, an object describing what happened
- Changes are Made with Pure Functions: Reducers are pure functions that take the previous state and an action, and return the next state
The Problem with Classic Redux
Traditional Redux requires a lot of boilerplate code:
// Classic Redux - Too much boilerplate! 😫
// Action types
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
const SET_COUNT = 'counter/SET_COUNT';
// Action creators
function increment() {
return { type: INCREMENT };
}
function decrement() {
return { type: DECREMENT };
}
function setCount(count: number) {
return { type: SET_COUNT, payload: count };
}
// Reducer
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0
};
function counterReducer(
state = initialState,
action: { type: string; payload?: any }
): CounterState {
switch (action.type) {
case INCREMENT:
return { ...state, value: state.value + 1 };
case DECREMENT:
return { ...state, value: state.value - 1 };
case SET_COUNT:
return { ...state, value: action.payload };
default:
return state;
}
}
⚠️ Problems with Classic Redux
- Too much boilerplate for simple operations
- Manual action type constants
- Repetitive action creators
- Verbose switch statements
- Manual immutable updates with spread operators
- Complex async logic setup
How Redux Toolkit Solves These Problems
Redux Toolkit reduces the same counter to just this:
// Redux Toolkit - Clean and concise! 🎉
import { createSlice } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1; // Looks like mutation, but it's actually immutable!
},
decrement: (state) => {
state.value -= 1;
},
setCount: (state, action) => {
state.value = action.payload;
}
}
});
export const { increment, decrement, setCount } = counterSlice.actions;
export default counterSlice.reducer;
✅ Redux Toolkit Benefits
- Less Boilerplate: Drastically reduced code
- Immer Integration: Write "mutating" logic that's actually immutable
- Auto-Generated Actions: No manual action creators needed
- Built-in DevTools: Redux DevTools support out of the box
- TypeScript Support: Excellent type inference
- RTK Query: Powerful data fetching and caching
When to Use Redux Toolkit
Redux Toolkit is ideal when you need:
| Use Redux Toolkit When... | Consider Alternatives When... |
|---|---|
| Complex, shared state across many components | Simple, local component state (use useState) |
| Predictable state updates with clear actions | Minimal state management needs (use Context) |
| Time-travel debugging and action history | Simpler API integration (use React Query) |
| Large team needing standardized patterns | Quick prototypes or small apps (use Zustand) |
| Middleware for complex side effects | Server-state only (use React Query/SWR) |
💡 The Sweet Spot for Redux Toolkit
RTK shines in medium-to-large applications with:
- Multiple features with interconnected state
- Need for time-travel debugging
- Complex state update logic
- Team that values explicit, traceable state changes
- Applications that will scale significantly
⚙️ Installation and Setup
Let's get Redux Toolkit up and running in a React TypeScript project.
Installing Redux Toolkit
# Install Redux Toolkit and React-Redux
npm install @reduxjs/toolkit react-redux
# For TypeScript projects, types are included!
# No need for @types packages
Project Structure
Organize your Redux code in a scalable way:
src/
store/
index.ts // Store configuration
hooks.ts // Typed hooks (useAppDispatch, useAppSelector)
features/
auth/
authSlice.ts // Auth feature slice
authAPI.ts // Auth API calls (optional)
cart/
cartSlice.ts // Shopping cart slice
products/
productsSlice.ts // Products slice
productsAPI.ts // Products API with RTK Query
components/
auth/
LoginForm.tsx // Uses auth slice
cart/
CartSummary.tsx // Uses cart slice
Creating the Store
Set up the Redux store with TypeScript support:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import authReducer from '../features/auth/authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
// Add more reducers here
},
// Redux Toolkit includes Redux DevTools by default in development!
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Creating Typed Hooks
Create pre-typed versions of Redux hooks for better TypeScript support:
// src/store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './index';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
✅ Why Custom Hooks?
Using useAppDispatch and useAppSelector instead of the plain Redux hooks gives you:
- Full TypeScript autocomplete for your state shape
- Type-safe dispatch of actions
- Compile-time errors for invalid selectors
- No need to manually type selectors
Providing the Store
Wrap your app with the Redux Provider:
// src/main.tsx (or App.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
💡 Redux DevTools
Redux Toolkit includes Redux DevTools Extension support by default. Install the browser extension to see:
- All dispatched actions
- State before and after each action
- Time-travel debugging (replay actions)
- State diff visualization
- Action stack traces
🍰 Creating Your First Slice
A "slice" is a collection of Redux reducer logic and actions for a single feature. Let's build a complete counter feature to understand all the concepts.
Basic Slice Structure
// src/features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// Define the shape of the state
interface CounterState {
value: number;
step: number;
}
// Define the initial state
const initialState: CounterState = {
value: 0,
step: 1
};
// Create the slice
const counterSlice = createSlice({
name: 'counter', // Used to generate action types
initialState,
reducers: {
// Reducer with no payload
increment: (state) => {
// Immer makes this safe!
state.value += state.step;
},
// Reducer with no payload
decrement: (state) => {
state.value -= state.step;
},
// Reducer with payload
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
// Reducer to update step
setStep: (state, action: PayloadAction<number>) => {
state.step = action.payload;
},
// Reducer to reset state
reset: (state) => {
state.value = 0;
state.step = 1;
}
}
});
// Export actions (automatically generated)
export const {
increment,
decrement,
incrementByAmount,
setStep,
reset
} = counterSlice.actions;
// Export reducer
export default counterSlice.reducer;
📖 Understanding PayloadAction
PayloadAction<T> is a TypeScript type from Redux Toolkit that represents an action with a typed payload. When you define action: PayloadAction<number>, you're saying "this action carries a number as its payload."
How Immer Works
Redux Toolkit uses Immer internally, which allows you to write code that looks like it's mutating state, but actually produces immutable updates:
// ❌ Without Immer (Classic Redux) - Manual immutability
function increment(state: CounterState): CounterState {
return {
...state,
value: state.value + state.step
};
}
// ✅ With Immer (Redux Toolkit) - Simpler "mutable" style
function increment(state: CounterState) {
state.value += state.step;
// Immer tracks changes and produces a new immutable state
}
Adding the Slice to the Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer, // Key determines how you access state
}
});
export type RootState = ReturnType<typeof store.getState>;
// RootState will be: { counter: CounterState }
export type AppDispatch = typeof store.dispatch;
Complex State Updates
Immer handles nested objects and arrays beautifully:
// src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
filter: 'all' | 'active' | 'completed';
}
const initialState: TodosState = {
items: [],
filter: 'all'
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// Immer lets us push to arrays!
state.items.push({
id: Date.now().toString(),
text: action.payload,
completed: false
});
},
toggleTodo: (state, action: PayloadAction<string>) => {
// Find and "mutate" - Immer handles immutability
const todo = state.items.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action: PayloadAction<string>) => {
// Filter works too
state.items = state.items.filter(t => t.id !== action.payload);
},
setFilter: (state, action: PayloadAction<'all' | 'active' | 'completed'>) => {
state.filter = action.payload;
}
}
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;
⚠️ Immer Limitations
Immer is powerful, but has a few rules:
- You must either mutate the
statedirectly OR return a new state, not both - Cannot reassign the entire
stateparameter (usereturn newStateinstead) - Doesn't work with non-plain objects (class instances)
// ❌ Bad: Both mutating AND returning
reducers: {
bad: (state) => {
state.value = 5;
return { value: 10 }; // DON'T DO THIS!
}
}
// ✅ Good: Either mutate...
reducers: {
good1: (state) => {
state.value = 5;
},
// ...OR return new state
good2: (state) => {
return { value: 10 };
}
}
⚛️ Using Redux in Components
Now let's use our Redux slices in React components with full TypeScript support.
Reading State with useAppSelector
// src/components/Counter.tsx
import { useAppSelector } from '../../store/hooks';
function Counter() {
// Select state from the store
const count = useAppSelector((state) => state.counter.value);
const step = useAppSelector((state) => state.counter.step);
return (
<div>
<h2>Count: {count}</h2>
<p>Step: {step}</p>
</div>
);
}
Dispatching Actions with useAppDispatch
// src/components/CounterControls.tsx
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
increment,
decrement,
incrementByAmount,
reset
} from '../../features/counter/counterSlice';
function CounterControls() {
const dispatch = useAppDispatch();
const count = useAppSelector((state) => state.counter.value);
return (
<div>
<h2>Count: {count}</h2>
<button onClick={() => dispatch(increment())}>
+1
</button>
<button onClick={() => dispatch(decrement())}>
-1
</button>
<button onClick={() => dispatch(incrementByAmount(5))}>
+5
</button>
<button onClick={() => dispatch(reset())}>
Reset
</button>
</div>
);
}
Selecting Multiple Values
// Option 1: Multiple selectors (best for performance)
function TodoList() {
const todos = useAppSelector((state) => state.todos.items);
const filter = useAppSelector((state) => state.todos.filter);
// Component only re-renders when todos or filter change
}
// Option 2: Single selector returning object (re-renders if ANY field changes)
function TodoList() {
const { items, filter } = useAppSelector((state) => ({
items: state.todos.items,
filter: state.todos.filter
}));
// Re-renders even if object contents are the same (new object reference)
}
Creating Selector Functions
For reusable, computed selectors:
// src/features/todos/todosSlice.ts
// ... slice definition ...
// Selector functions
export const selectAllTodos = (state: RootState) => state.todos.items;
export const selectActiveTodos = (state: RootState) =>
state.todos.items.filter(todo => !todo.completed);
export const selectCompletedTodos = (state: RootState) =>
state.todos.items.filter(todo => todo.completed);
export const selectFilteredTodos = (state: RootState) => {
const { items, filter } = state.todos;
switch (filter) {
case 'active':
return items.filter(t => !t.completed);
case 'completed':
return items.filter(t => t.completed);
default:
return items;
}
};
// Usage in components
import { useAppSelector } from '../../store/hooks';
import { selectFilteredTodos } from './todosSlice';
function TodoList() {
const filteredTodos = useAppSelector(selectFilteredTodos);
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
✅ Benefits of Selector Functions
- Reusability: Use the same selector in multiple components
- Testability: Easy to test selectors independently
- Maintainability: Change selector logic in one place
- Encapsulation: Hide state structure details
- Performance: Can be memoized with Reselect
Complete Todo Example
// src/components/TodoApp.tsx
import React, { useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
addTodo,
toggleTodo,
removeTodo,
setFilter,
selectFilteredTodos
} from '../../features/todos/todosSlice';
function TodoApp() {
const dispatch = useAppDispatch();
const filteredTodos = useAppSelector(selectFilteredTodos);
const filter = useAppSelector((state) => state.todos.filter);
const [inputText, setInputText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputText.trim()) {
dispatch(addTodo(inputText));
setInputText('');
}
};
return (
<div>
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
<div>
<button
onClick={() => dispatch(setFilter('all'))}
disabled={filter === 'all'}
>
All
</button>
<button
onClick={() => dispatch(setFilter('active'))}
disabled={filter === 'active'}
>
Active
</button>
<button
onClick={() => dispatch(setFilter('completed'))}
disabled={filter === 'completed'}
>
Completed
</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}
>
{todo.text}
</span>
<button onClick={() => dispatch(removeTodo(todo.id))}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
💡 Component Best Practices
- Use
useAppSelectoranduseAppDispatchinstead of plain Redux hooks - Select only the data you need (avoid selecting entire slices)
- Extract selector logic into selector functions for reusability
- Dispatch actions rather than calling action creators directly
- Keep local UI state in
useState, only put shared state in Redux
📘 TypeScript Integration
Redux Toolkit has excellent TypeScript support built-in. Let's explore advanced typing patterns to make your Redux code fully type-safe.
Typing the Store
We already created typed hooks, but let's understand the types in depth:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
import authReducer from '../features/auth/authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
auth: authReducer
}
});
// Extract the root state type from the store
export type RootState = ReturnType<typeof store.getState>;
// RootState = {
// counter: CounterState,
// todos: TodosState,
// auth: AuthState
// }
// Extract the dispatch type
export type AppDispatch = typeof store.dispatch;
// Optional: Extract store type itself
export type AppStore = typeof store;
Typing Slices with Prepare Callbacks
Sometimes you need to transform or add metadata to actions. Use prepare callbacks:
// src/features/posts/postsSlice.ts
import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';
interface Post {
id: string;
title: string;
content: string;
createdAt: string;
}
interface PostsState {
posts: Post[];
}
const initialState: PostsState = {
posts: []
};
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// Simple action - RTK generates the prepare function
addPost: {
// Reducer function
reducer: (state, action: PayloadAction<Post>) => {
state.posts.push(action.payload);
},
// Prepare function - transforms arguments into payload
prepare: (title: string, content: string) => {
return {
payload: {
id: nanoid(), // Generate unique ID
title,
content,
createdAt: new Date().toISOString()
}
};
}
},
// You can also add metadata
postUpdated: {
reducer: (state, action: PayloadAction<Post, string, { updatedAt: string }>) => {
const index = state.posts.findIndex(p => p.id === action.payload.id);
if (index !== -1) {
state.posts[index] = action.payload;
}
},
prepare: (post: Post) => {
return {
payload: post,
meta: {
updatedAt: new Date().toISOString()
}
};
}
}
}
});
export const { addPost, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
// Usage in component
function CreatePost() {
const dispatch = useAppDispatch();
const handleSubmit = (title: string, content: string) => {
// ID and timestamp added automatically!
dispatch(addPost(title, content));
};
}
📖 Understanding Prepare Callbacks
Prepare callbacks let you customize how action payloads are created. They're useful for generating IDs, adding timestamps, or transforming multiple arguments into a single payload object. The prepare function returns an object with payload and optionally meta and error.
Typing Action Creators
Action creators generated by createSlice are fully typed:
import { addTodo, toggleTodo } from './todosSlice';
// Action creators have proper types
const action1 = addTodo('Learn Redux');
// type: { type: 'todos/addTodo', payload: string }
const action2 = toggleTodo('todo-id-123');
// type: { type: 'todos/toggleTodo', payload: string }
// TypeScript error if wrong payload type
const action3 = addTodo(123); // ❌ Error: number not assignable to string
Typing Selectors
Create strongly-typed selectors:
// src/features/todos/todosSelectors.ts
import { RootState } from '../../store';
import { createSelector } from '@reduxjs/toolkit';
// Basic selector with explicit typing
export const selectTodos = (state: RootState) => state.todos.items;
export const selectFilter = (state: RootState) => state.todos.filter;
// Memoized selector with createSelector
export const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
}
);
// Selector with parameter
export const selectTodoById = (state: RootState, todoId: string) =>
state.todos.items.find(todo => todo.id === todoId);
// Memoized parametric selector
export const makeSelectTodoById = () =>
createSelector(
[selectTodos, (_state: RootState, todoId: string) => todoId],
(todos, todoId) => todos.find(todo => todo.id === todoId)
);
✅ Benefits of createSelector
Redux Toolkit re-exports createSelector from Reselect. It provides:
- Memoization: Only recalculates when inputs change
- Composition: Build complex selectors from simple ones
- Performance: Prevents unnecessary recalculations
- Type Safety: Input and output types are inferred
Typing Middleware
If you add custom middleware, type it properly:
// src/store/middleware/logger.ts
import { Middleware } from '@reduxjs/toolkit';
import { RootState } from '../index';
export const loggerMiddleware: Middleware<{}, RootState> =
(storeAPI) => (next) => (action) => {
console.log('Dispatching:', action);
console.log('Current state:', storeAPI.getState());
const result = next(action);
console.log('Next state:', storeAPI.getState());
return result;
};
// Add to store
export const store = configureStore({
reducer: {
// ... reducers
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware)
});
Typing Extra Reducers
When handling actions from other slices or async thunks, use extraReducers:
// src/features/notifications/notificationsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { addTodo, toggleTodo } from '../todos/todosSlice';
interface Notification {
id: string;
message: string;
type: 'info' | 'success' | 'error';
}
interface NotificationsState {
notifications: Notification[];
}
const initialState: NotificationsState = {
notifications: []
};
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
addNotification: (state, action: PayloadAction<Notification>) => {
state.notifications.push(action.payload);
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(
n => n.id !== action.payload
);
}
},
extraReducers: (builder) => {
// Listen to actions from other slices
builder
.addCase(addTodo, (state) => {
state.notifications.push({
id: Date.now().toString(),
message: 'Todo added successfully!',
type: 'success'
});
})
.addCase(toggleTodo, (state) => {
state.notifications.push({
id: Date.now().toString(),
message: 'Todo status updated!',
type: 'info'
});
});
}
});
export const { addNotification, removeNotification } = notificationsSlice.actions;
export default notificationsSlice.reducer;
💡 When to Use extraReducers
Use extraReducers when you need to:
- Respond to actions defined in other slices
- Handle async thunk actions (pending, fulfilled, rejected)
- Cross-slice communication
- Side effects triggered by actions from elsewhere
Generic Slice Patterns
Create reusable slice factories with generics:
// src/store/genericSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface LoadingState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
}
function createLoadingSlice<T>(name: string) {
const initialState: LoadingState<T> = {
data: null,
isLoading: false,
error: null
};
return createSlice({
name,
initialState,
reducers: {
startLoading: (state) => {
state.isLoading = true;
state.error = null;
},
loadSuccess: (state, action: PayloadAction<T>) => {
state.data = action.payload;
state.isLoading = false;
state.error = null;
},
loadFailure: (state, action: PayloadAction<string>) => {
state.isLoading = false;
state.error = action.payload;
},
reset: (state) => {
state.data = null;
state.isLoading = false;
state.error = null;
}
}
});
}
// Usage
interface User {
id: string;
name: string;
email: string;
}
interface Product {
id: string;
name: string;
price: number;
}
const userSlice = createLoadingSlice<User>('user');
const productsSlice = createLoadingSlice<Product[]>('products');
export const userActions = userSlice.actions;
export const productsActions = productsSlice.actions;
Strict Typing for Action Matchers
Use type guards and matchers for complex scenarios:
import {
createSlice,
isAnyOf,
isPending,
isFulfilled,
isRejected
} from '@reduxjs/toolkit';
import { fetchUser, updateUser, deleteUser } from './userThunks';
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: (builder) => {
// Match multiple actions at once
builder
.addMatcher(
isAnyOf(fetchUser.pending, updateUser.pending, deleteUser.pending),
(state) => {
state.isLoading = true;
}
)
.addMatcher(
isAnyOf(fetchUser.fulfilled, updateUser.fulfilled),
(state, action) => {
state.user = action.payload;
state.isLoading = false;
}
)
.addMatcher(
isAnyOf(fetchUser.rejected, updateUser.rejected, deleteUser.rejected),
(state, action) => {
state.error = action.error.message || 'Something went wrong';
state.isLoading = false;
}
);
}
});
⚠️ TypeScript Best Practices
- Always define state interfaces before creating slices
- Use
PayloadAction<T>for typed action payloads - Extract
RootStateandAppDispatchtypes - Create typed hooks (
useAppSelector,useAppDispatch) - Use
createSelectorfor memoized, typed selectors - Leverage TypeScript's inference - don't over-annotate
⏳ Async Operations with createAsyncThunk
Most real applications need to fetch data from APIs. Redux Toolkit provides createAsyncThunk to handle async logic with automatic action creators for pending, fulfilled, and rejected states.
Understanding createAsyncThunk
When you create an async thunk, Redux Toolkit automatically generates three action types:
pending: Dispatched immediately when thunk is calledfulfilled: Dispatched when promise resolves successfullyrejected: Dispatched when promise rejects or throws error
Basic Async Thunk
// src/features/users/usersSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface UsersState {
users: User[];
isLoading: boolean;
error: string | null;
}
const initialState: UsersState = {
users: [],
isLoading: false,
error: null
};
// Create async thunk
export const fetchUsers = createAsyncThunk(
'users/fetchUsers', // Action type prefix
async () => {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
return data as User[]; // This becomes the 'fulfilled' action payload
}
);
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
// Handle pending state
.addCase(fetchUsers.pending, (state) => {
state.isLoading = true;
state.error = null;
})
// Handle fulfilled state
.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.users = action.payload;
state.isLoading = false;
})
// Handle rejected state
.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch users';
});
}
});
export default usersSlice.reducer;
Using Async Thunks in Components
// src/components/UserList.tsx
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { fetchUsers } from '../../features/users/usersSlice';
function UserList() {
const dispatch = useAppDispatch();
const { users, isLoading, error } = useAppSelector((state) => state.users);
useEffect(() => {
// Dispatch the async thunk
dispatch(fetchUsers());
}, [dispatch]);
if (isLoading) {
return <div>Loading users...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
Async Thunks with Arguments
// Fetch a single user by ID
export const fetchUserById = createAsyncThunk(
'users/fetchUserById',
async (userId: string) => {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('User not found');
}
return await response.json() as User;
}
);
// Create a new user
export const createUser = createAsyncThunk(
'users/createUser',
async (userData: Omit<User, 'id'>) => {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return await response.json() as User;
}
);
// Update existing user
export const updateUser = createAsyncThunk(
'users/updateUser',
async ({ id, updates }: { id: string; updates: Partial<User> }) => {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error('Failed to update user');
}
return await response.json() as User;
}
);
// Delete user
export const deleteUser = createAsyncThunk(
'users/deleteUser',
async (userId: string) => {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
return userId; // Return the deleted user's ID
}
);
Handling All CRUD Operations
// Complete users slice with CRUD operations
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// Synchronous actions if needed
clearError: (state) => {
state.error = null;
}
},
extraReducers: (builder) => {
builder
// Fetch all users
.addCase(fetchUsers.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.users = action.payload;
state.isLoading = false;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch users';
})
// Create user
.addCase(createUser.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(createUser.fulfilled, (state, action) => {
state.users.push(action.payload);
state.isLoading = false;
})
.addCase(createUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to create user';
})
// Update user
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.users.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.users[index] = action.payload;
}
})
// Delete user
.addCase(deleteUser.fulfilled, (state, action) => {
state.users = state.users.filter(u => u.id !== action.payload);
});
}
});
export const { clearError } = usersSlice.actions;
export default usersSlice.reducer;
Custom Error Handling
// Thunk with custom error handling
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
// Return custom error
return rejectWithValue({
message: 'Failed to fetch users',
status: response.status
});
}
return await response.json() as User[];
} catch (error) {
// Handle network errors
if (error instanceof Error) {
return rejectWithValue({
message: error.message,
status: 0
});
}
return rejectWithValue({
message: 'Unknown error occurred',
status: 0
});
}
}
);
// Handle in reducer
.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
if (action.payload) {
// Custom error from rejectWithValue
state.error = `Error ${action.payload.status}: ${action.payload.message}`;
} else {
// Standard error
state.error = action.error.message || 'Failed to fetch users';
}
});
✅ createAsyncThunk Best Practices
- Use
rejectWithValuefor custom error payloads - Always handle pending, fulfilled, and rejected states
- Use TypeScript generics for payload and return types
- Extract API calls to separate service files
- Use thunkAPI for accessing state, dispatch, etc.
- Add loading indicators for better UX
Accessing State and Dispatch in Thunks
The thunkAPI provides access to store state, dispatch, and more:
export const conditionalFetch = createAsyncThunk(
'users/conditionalFetch',
async (_, { getState, dispatch, rejectWithValue }) => {
const state = getState() as RootState;
// Only fetch if not already loaded
if (state.users.users.length > 0) {
return state.users.users;
}
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Fetch failed');
}
const users = await response.json();
// Dispatch another action if needed
dispatch(someOtherAction());
return users as User[];
} catch (error) {
return rejectWithValue('Failed to fetch');
}
}
);
Canceling Thunks
// In component
function UserList() {
const dispatch = useAppDispatch();
useEffect(() => {
// Dispatch returns a promise with an abort method
const promise = dispatch(fetchUsers());
// Cancel on unmount
return () => {
promise.abort();
};
}, [dispatch]);
}
Sequential and Parallel Thunks
// Sequential: Wait for one before starting the next
async function loadUserData(userId: string) {
const dispatch = useAppDispatch();
try {
// Wait for user to load
const user = await dispatch(fetchUserById(userId)).unwrap();
// Then load their posts
await dispatch(fetchUserPosts(user.id)).unwrap();
// Then load their comments
await dispatch(fetchUserComments(user.id)).unwrap();
} catch (error) {
console.error('Failed to load user data:', error);
}
}
// Parallel: Start all at once
async function loadDashboardData() {
const dispatch = useAppDispatch();
try {
// Start all requests simultaneously
await Promise.all([
dispatch(fetchUsers()).unwrap(),
dispatch(fetchPosts()).unwrap(),
dispatch(fetchComments()).unwrap()
]);
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
💡 The unwrap() Method
Calling .unwrap() on the returned promise gives you:
- The fulfilled payload directly (no action wrapper)
- A thrown error if the thunk was rejected
- Ability to use try-catch for error handling
- Better TypeScript inference for the result
try {
const user = await dispatch(fetchUser(id)).unwrap();
// user is typed as User, not PayloadAction<User>
console.log(user.name);
} catch (error) {
// Handle error
console.error('Failed:', error);
}
🚀 RTK Query
RTK Query is Redux Toolkit's powerful data fetching and caching solution. It's like React Query, but integrated directly into Redux with automatic cache management, optimistic updates, and more.
Why RTK Query?
RTK Query solves common problems in data fetching:
📖 Definition
RTK Query is a data fetching and caching tool built into Redux Toolkit. It automatically manages loading states, caching, invalidation, and refetching, eliminating the need for hand-written async thunks for most data fetching scenarios.
| Without RTK Query | With RTK Query |
|---|---|
| Manually write thunks for each endpoint | Define endpoints once, hooks generated automatically |
| Manage loading states yourself | Loading states provided automatically |
| Handle caching manually | Automatic caching and invalidation |
| Write refetch logic | Automatic refetching and polling |
| Complex optimistic updates | Built-in optimistic update helpers |
Setting Up RTK Query
// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
userId: string;
title: string;
body: string;
}
// Define API slice
export const api = createApi({
reducerPath: 'api', // The key in the Redux store
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com'
}),
tagTypes: ['User', 'Post'], // For cache invalidation
endpoints: (builder) => ({
// Query endpoints (GET requests)
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'] // This data is tagged as 'User'
}),
getUserById: builder.query<User, string>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }]
}),
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post']
}),
// Mutation endpoints (POST, PUT, DELETE)
createUser: builder.mutation<User, Omit<User, 'id'>>({
query: (newUser) => ({
url: '/users',
method: 'POST',
body: newUser
}),
invalidatesTags: ['User'] // Invalidate User cache after creating
}),
updateUser: builder.mutation<User, { id: string; updates: Partial<User> }>({
query: ({ id, updates }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: updates
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }]
}),
deleteUser: builder.mutation<void, string>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE'
}),
invalidatesTags: ['User']
})
})
});
// Export hooks - automatically generated!
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useGetPostsQuery,
useCreateUserMutation,
useUpdateUserMutation,
useDeleteUserMutation
} = api;
Adding API to Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { api } from '../services/api';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
// Add the API reducer
[api.reducerPath]: api.reducer,
counter: counterReducer,
// ... other reducers
},
// Add the API middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware)
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
⚠️ Don't Forget Middleware!
RTK Query requires its middleware to be added to the store. This middleware handles:
- Caching and cache invalidation
- Automatic refetching
- Request deduplication
- Background polling
Using Query Hooks
// src/components/UserList.tsx
import { useGetUsersQuery } from '../services/api';
function UserList() {
// Automatically fetches data and provides loading/error states
const { data: users, isLoading, isError, error } = useGetUsersQuery();
if (isLoading) {
return <div>Loading users...</div>;
}
if (isError) {
return <div>Error: {error.toString()}</div>;
}
return (
<ul>
{users?.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
// Query with parameter
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useGetUserByIdQuery(userId);
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Using Mutation Hooks
// src/components/CreateUserForm.tsx
import { useState } from 'react';
import { useCreateUserMutation } from '../services/api';
function CreateUserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// Mutation hook returns [trigger function, result object]
const [createUser, { isLoading, isSuccess, isError }] = useCreateUserMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Call the mutation
await createUser({ name, email }).unwrap();
// Reset form on success
setName('');
setEmail('');
alert('User created successfully!');
} catch (error) {
alert('Failed to create user');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
required
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
type="email"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
{isSuccess && <p>User created!</p>}
{isError && <p>Failed to create user</p>}
</form>
);
}
✅ RTK Query Features
Out of the box, RTK Query provides:
- Automatic Caching: Requests are cached and shared
- De-duplication: Multiple components requesting the same data = 1 request
- Cache Invalidation: Automatic refetch when cache is invalidated
- Loading States:
isLoading,isFetching,isSuccess,isError - Polling: Automatic refetching at intervals
- Optimistic Updates: Update UI before server responds
- TypeScript: Full type safety for requests and responses
Query Options and Configuration
RTK Query hooks accept options to customize behavior:
function UserList() {
const { data: users } = useGetUsersQuery(undefined, {
pollingInterval: 5000, // Refetch every 5 seconds
refetchOnMountOrArgChange: true, // Refetch on mount
refetchOnFocus: true, // Refetch when window gains focus
refetchOnReconnect: true, // Refetch on network reconnect
skip: false // Skip the query (useful for conditional fetching)
});
return <div>{/* render users */}</div>;
}
// Conditional fetching
function UserProfile({ userId }: { userId: string | null }) {
const { data: user } = useGetUserByIdQuery(userId!, {
skip: !userId // Don't fetch if userId is null
});
return user ? <div>{user.name}</div> : null;
}
Manual Cache Management
Sometimes you need to manually refetch or invalidate cache:
import { useGetUsersQuery } from '../services/api';
function UserList() {
const { data: users, refetch } = useGetUsersQuery();
return (
<div>
<button onClick={() => refetch()}>
Refresh Users
</button>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
// Using useLazyQuery for on-demand fetching
import { useLazyGetUserByIdQuery } from '../services/api';
function SearchUser() {
const [trigger, result] = useLazyGetUserByIdQuery();
const [userId, setUserId] = useState('');
const handleSearch = () => {
if (userId) {
trigger(userId); // Manually trigger the query
}
};
return (
<div>
<input value={userId} onChange={(e) => setUserId(e.target.value)} />
<button onClick={handleSearch}>Search</button>
{result.isLoading && <div>Loading...</div>}
{result.data && <div>Found: {result.data.name}</div>}
</div>
);
}
Optimistic Updates
Update the UI immediately before the server responds:
// src/services/api.ts
export const api = createApi({
// ... base config
endpoints: (builder) => ({
updateUser: builder.mutation<User, { id: string; updates: Partial<User> }>({
query: ({ id, updates }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: updates
}),
// Optimistic update
async onQueryStarted({ id, updates }, { dispatch, queryFulfilled }) {
// Optimistically update the cache
const patchResult = dispatch(
api.util.updateQueryData('getUserById', id, (draft) => {
Object.assign(draft, updates);
})
);
try {
// Wait for the mutation to complete
await queryFulfilled;
} catch {
// Undo the optimistic update on error
patchResult.undo();
}
}
}),
deleteUser: builder.mutation<void, string>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE'
}),
// Optimistically remove from list
async onQueryStarted(id, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData('getUsers', undefined, (draft) => {
const index = draft.findIndex(user => user.id === id);
if (index !== -1) {
draft.splice(index, 1);
}
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
}
})
})
});
Transform Responses
Transform API responses before they reach your components:
export const api = createApi({
// ... config
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
// Transform response
transformResponse: (response: any[]) => {
// Add a computed field
return response.map(user => ({
...user,
fullName: `${user.firstName} ${user.lastName}`,
isActive: user.status === 'active'
}));
},
// Transform error response
transformErrorResponse: (response: { status: number; data: any }) => {
return {
status: response.status,
message: response.data?.message || 'An error occurred'
};
}
})
})
});
Advanced Cache Invalidation
export const api = createApi({
// ... config
tagTypes: ['User', 'Post', 'Comment'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
// Provide tags for each user
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'User' as const, id })),
{ type: 'User', id: 'LIST' }
]
: [{ type: 'User', id: 'LIST' }]
}),
getUserPosts: builder.query<Post[], string>({
query: (userId) => `/users/${userId}/posts`,
providesTags: (result, error, userId) =>
result
? [
...result.map(({ id }) => ({ type: 'Post' as const, id })),
{ type: 'Post', id: userId }
]
: [{ type: 'Post', id: userId }]
}),
createPost: builder.mutation<Post, Omit<Post, 'id'>>({
query: (post) => ({
url: '/posts',
method: 'POST',
body: post
}),
// Invalidate specific user's posts and the list
invalidatesTags: (result, error, { userId }) => [
{ type: 'Post', id: userId },
{ type: 'Post', id: 'LIST' }
]
}),
deleteUser: builder.mutation<void, string>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE'
}),
// Invalidate both the specific user and the list
invalidatesTags: (result, error, id) => [
{ type: 'User', id },
{ type: 'User', id: 'LIST' }
]
})
})
});
💡 RTK Query Pro Tips
- Tag Strategy: Use specific IDs for granular invalidation, 'LIST' for collection endpoints
- Polling: Great for real-time dashboards, but be mindful of server load
- Optimistic Updates: Improve perceived performance but always handle rollback
- Skip Option: Use for conditional fetching to avoid unnecessary requests
- Transform Responses: Normalize or enhance data in one place
Code Splitting API Slices
For large applications, split your API into multiple files:
// src/services/baseApi.ts
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com' }),
tagTypes: ['User', 'Post', 'Comment'],
endpoints: () => ({}) // Empty initially
});
// src/services/users.ts
import { baseApi } from './baseApi';
export const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User']
}),
// ... other user endpoints
})
});
export const { useGetUsersQuery } = usersApi;
// src/services/posts.ts
import { baseApi } from './baseApi';
export const postsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post']
}),
// ... other post endpoints
})
});
export const { useGetPostsQuery } = postsApi;
// In store, only add baseApi
export const store = configureStore({
reducer: {
[baseApi.reducerPath]: baseApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware)
});
⚠️ When NOT to Use RTK Query
RTK Query is powerful, but not always the right choice:
- Simple, one-off API calls (use fetch or axios directly)
- Non-REST APIs with complex protocols
- Heavy GraphQL usage (consider Apollo Client)
- Real-time with WebSockets (needs custom setup)
- Projects already using React Query successfully
⭐ Best Practices
Let's consolidate everything into actionable best practices for Redux Toolkit.
Slice Organization
1. Feature-Based Structure
src/
features/
auth/
authSlice.ts // Slice definition
authSelectors.ts // Memoized selectors
authThunks.ts // Complex async thunks (optional)
types.ts // TypeScript types
cart/
cartSlice.ts
cartSelectors.ts
products/
productsSlice.ts
productsAPI.ts // RTK Query API
store/
index.ts // Store configuration
hooks.ts // Typed hooks
middleware.ts // Custom middleware (if needed)
2. Keep Slices Focused
// ✅ Good: Focused slice
interface CartState {
items: CartItem[];
total: number;
}
// ❌ Bad: Kitchen sink slice
interface AppState {
users: User[];
products: Product[];
cart: CartItem[];
auth: AuthData;
ui: UIState;
// Everything in one slice!
}
State Shape Design
1. Normalize Nested Data
// ❌ Bad: Nested arrays
interface State {
users: {
id: string;
name: string;
posts: {
id: string;
title: string;
comments: Comment[];
}[];
}[];
}
// ✅ Good: Normalized structure
interface State {
users: {
byId: { [id: string]: User };
allIds: string[];
};
posts: {
byId: { [id: string]: Post };
allIds: string[];
};
comments: {
byId: { [id: string]: Comment };
allIds: string[];
};
}
2. Separate UI State from Domain State
// UI state - belongs in components or UI slice
interface UIState {
isSidebarOpen: boolean;
activeModal: string | null;
theme: 'light' | 'dark';
}
// Domain state - belongs in feature slices
interface ProductsState {
items: Product[];
isLoading: boolean;
error: string | null;
}
Performance Optimization
1. Use Memoized Selectors
import { createSelector } from '@reduxjs/toolkit';
// ✅ Good: Memoized with createSelector
export const selectFilteredTodos = createSelector(
[(state: RootState) => state.todos.items,
(state: RootState) => state.todos.filter],
(items, filter) => {
// Expensive filtering only runs when items or filter change
return items.filter(item => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
}
);
// ❌ Bad: Filtering in component or non-memoized selector
function TodoList() {
const items = useAppSelector(state => state.todos.items);
const filter = useAppSelector(state => state.todos.filter);
// Runs on every render!
const filtered = items.filter(item => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
}
2. Select Minimal Data
// ✅ Good: Select only what you need
function UserName({ userId }: { userId: string }) {
const name = useAppSelector(state =>
state.users.byId[userId]?.name
);
return <div>{name}</div>;
}
// ❌ Bad: Select entire user
function UserName({ userId }: { userId: string }) {
const user = useAppSelector(state => state.users.byId[userId]);
// Re-renders when ANY user property changes
return <div>{user?.name}</div>;
}
Async Logic Best Practices
1. Choose the Right Tool
| Use Case | Recommended Approach |
|---|---|
| REST API CRUD operations | RTK Query |
| Complex multi-step async logic | createAsyncThunk |
| Simple one-off API call | useEffect with fetch |
| Real-time data (WebSocket) | Custom middleware |
2. Handle All Async States
// ✅ Good: Handle all states
const usersSlice = createSlice({
name: 'users',
initialState: {
data: null,
isLoading: false,
error: null
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.data = action.payload;
state.isLoading = false;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed';
});
}
});
TypeScript Best Practices
1. Define Interfaces First
// ✅ Good: Clear type definitions
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
filter: 'all' | 'active' | 'completed';
isLoading: boolean;
error: string | null;
}
// Then use them
const initialState: TodosState = {
items: [],
filter: 'all',
isLoading: false,
error: null
};
2. Use Type Inference
// ✅ Good: Let TypeScript infer
const increment = (state: CounterState) => {
state.value += 1; // Type of value is inferred
};
// ❌ Unnecessary: Over-annotation
const increment = (state: CounterState): void => {
state.value = (state.value as number) + (1 as number);
};
Testing Redux
// Testing reducers
import counterReducer, { increment, decrement } from './counterSlice';
describe('counter reducer', () => {
const initialState = { value: 0, step: 1 };
it('should handle increment', () => {
const actual = counterReducer(initialState, increment());
expect(actual.value).toEqual(1);
});
it('should handle decrement', () => {
const actual = counterReducer(initialState, decrement());
expect(actual.value).toEqual(-1);
});
});
// Testing selectors
import { selectFilteredTodos } from './todosSelectors';
describe('selectFilteredTodos', () => {
const state = {
todos: {
items: [
{ id: '1', text: 'Test', completed: false },
{ id: '2', text: 'Done', completed: true }
],
filter: 'active'
}
};
it('returns active todos when filter is active', () => {
const result = selectFilteredTodos(state as RootState);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
});
// Testing components with Redux
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import Counter from './Counter';
function renderWithRedux(
ui: React.ReactElement,
{ initialState, ...renderOptions } = {}
) {
const store = configureStore({
reducer: { counter: counterReducer },
preloadedState: initialState
});
return render(
<Provider store={store}>{ui}</Provider>,
renderOptions
);
}
test('displays count', () => {
renderWithRedux(<Counter />, {
initialState: { counter: { value: 5, step: 1 } }
});
expect(screen.getByText(/count: 5/i)).toBeInTheDocument();
});
✅ Redux Toolkit Golden Rules
- Use RTK, not classic Redux - Less boilerplate, better DX
- Feature-based organization - Group by feature, not by type
- Normalize complex data - Avoid deep nesting
- Use RTK Query for APIs - Built-in caching and loading states
- createSelector for derived data - Memoize expensive computations
- Type everything - Full TypeScript support
- Keep slices focused - Single responsibility per slice
- Test reducer logic - Pure functions are easy to test
📝 Summary
Congratulations! You've mastered Redux Toolkit, the modern, powerful way to manage state in React applications. Let's recap what you've learned:
Key Takeaways
🎯 What You've Learned
- Redux Fundamentals: Understanding Redux principles and when to use it
- Redux Toolkit Setup: Configuring store with TypeScript support
- Creating Slices: Using createSlice with Immer for simpler state updates
- Async Operations: Handling async logic with createAsyncThunk
- RTK Query: Powerful data fetching with automatic caching
- TypeScript Integration: Full type safety for actions, state, and selectors
- Best Practices: Organizing code, optimizing performance, and testing
Redux Toolkit vs Alternatives
| Feature | Redux Toolkit | Zustand | Context API |
|---|---|---|---|
| Bundle Size | ~8KB | ~1KB | 0 (built-in) |
| Boilerplate | Low (with RTK) | Minimal | Low-Moderate |
| DevTools | Excellent | Good (via middleware) | No |
| Time Travel | Yes | No | No |
| Middleware | Extensive ecosystem | Limited | No |
| Data Fetching | RTK Query (excellent) | Manual | Manual |
| Learning Curve | Moderate | Easy | Easy |
| Best For | Large, complex apps | Small-medium apps | Simple state sharing |
Quick Reference
Creating a Slice
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
setCount: (state, action: PayloadAction<number>) => {
state.value = action.payload;
}
}
});
export const { increment, decrement, setCount } = counterSlice.actions;
export default counterSlice.reducer;
Async Thunk
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await fetch('/api/users');
return response.json();
}
);
// In slice
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.isLoading = true;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.users = action.payload;
state.isLoading = false;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.error = action.error.message;
state.isLoading = false;
});
}
RTK Query Setup
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User']
}),
createUser: builder.mutation<User, Partial<User>>({
query: (user) => ({
url: '/users',
method: 'POST',
body: user
}),
invalidatesTags: ['User']
})
})
});
export const { useGetUsersQuery, useCreateUserMutation } = api;
When to Choose Redux Toolkit
Redux Toolkit is the right choice when you need:
- ✅ Complex state logic with many actions
- ✅ Time-travel debugging capabilities
- ✅ Predictable, traceable state updates
- ✅ Extensive middleware ecosystem
- ✅ Large team with standardized patterns
- ✅ Powerful data fetching with RTK Query
- ✅ Applications that will scale significantly
Consider alternatives when you have:
- ❌ Simple state needs (use Context or Zustand)
- ❌ Small prototypes or MVPs (use Zustand)
- ❌ Mostly server state (use React Query)
- ❌ Need minimal bundle size (use Zustand)
Next Steps
- Practice: Build a complete app with Redux Toolkit
- Explore: Try RTK Query for all data fetching
- Compare: Build the same feature with Redux and Zustand
- Learn: Explore Redux middleware ecosystem
- Master: Advanced patterns like entity adapters
💡 Final Thoughts
Redux Toolkit has transformed Redux from a verbose, boilerplate-heavy library into a developer-friendly, powerful state management solution. While it may be overkill for small apps, for medium-to-large applications with complex state needs, it's an excellent choice that will scale with your project.
Resources
🏋️ Practice Exercises
Exercise 1: Shopping Cart with Redux Toolkit
Objective: Build a complete shopping cart feature using Redux Toolkit.
Requirements:
- Create a cart slice with add, remove, update quantity actions
- Calculate totals with memoized selectors
- Add discount codes functionality
- Persist cart to localStorage using middleware
- Full TypeScript typing
💡 Hint
Start with this structure:
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
discountCode: string | null;
discountPercent: number;
}
Use createSelector to calculate totals:
export const selectCartTotal = createSelector(
[(state: RootState) => state.cart.items,
(state: RootState) => state.cart.discountPercent],
(items, discount) => {
const subtotal = items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
return subtotal * (1 - discount / 100);
}
);
Exercise 2: Blog Platform with RTK Query
Objective: Create a blog platform using RTK Query for all data operations.
Requirements:
- Set up RTK Query API with posts and comments endpoints
- Implement CRUD operations for posts
- Add nested comments functionality
- Use optimistic updates for creating posts
- Implement proper cache invalidation
- Add polling for real-time updates
💡 Hint
API structure:
export const blogApi = createApi({
reducerPath: 'blogApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'Comment'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post']
}),
getPostComments: builder.query<Comment[], string>({
query: (postId) => `/posts/${postId}/comments`,
providesTags: (result, error, postId) =>
[{ type: 'Comment', id: postId }]
}),
// Add more endpoints...
})
});
Exercise 3: Dashboard with Async Thunks
Objective: Build an admin dashboard with complex async operations.
Requirements:
- Fetch users, products, and orders data
- Handle sequential data loading (users → their orders → order details)
- Implement error handling and retry logic
- Add loading states for each data type
- Create memoized selectors for dashboard stats
- Handle authentication token refreshing
❓ Knowledge Check
Question 1: Immer in Redux Toolkit
Why can you write "mutating" code in Redux Toolkit reducers?
Show Answer
Answer: B
Immer uses JavaScript Proxy objects to track all changes you make to a draft state. Behind the scenes, it produces a completely new immutable state based on those changes. This allows you to write simpler code that looks like mutations while maintaining immutability.
Question 2: When to Use RTK Query
Which scenario is RTK Query BEST suited for?
Show Answer
Answer: B
RTK Query excels at REST APIs with CRUD operations. It provides automatic caching, cache invalidation, loading states, and request deduplication. For simple one-off calls, plain fetch is sufficient. For WebSockets, you'd need custom middleware or other solutions.
Question 3: createAsyncThunk States
What three action types does createAsyncThunk automatically generate?
Show Answer
Answer: B
createAsyncThunk generates three action types following Promise terminology: pending (when the async function starts), fulfilled (when it completes successfully), and rejected (when it throws an error or returns a rejected promise). You handle these in extraReducers.