Day 4: Async Operations
What You'll Learn Today
- Why async operations in Redux require middleware
- How Redux Thunk works and its built-in support in RTK
createAsyncThunklifecycle management (pending / fulfilled / rejected)- Managing loading state with
extraReducersandbuilder.addCase - Error handling patterns (rejectWithValue)
- Cancellation with
AbortController - Comparison: Thunk vs Saga vs Observable
Why Redux Needs Middleware for Async Operations
Redux actions are plain objects. Reducers must be pure functions. This means you cannot perform side effects like API calls or timers directly inside a reducer.
// This won't work! Actions must be plain objects
function fetchUsers() {
// Bad: executing async logic directly in action creator
const response = await fetch("/api/users");
const data = await response.json();
return { type: "users/loaded", payload: data };
}
Middleware intercepts between dispatch and the reducer. This allows you to dispatch things other than plain objects, such as functions or Promises.
sequenceDiagram
participant C as Component
participant D as dispatch
participant M as Middleware (Thunk)
participant R as Reducer
participant S as Store
participant API as API Server
C->>D: dispatch(fetchUsers())
D->>M: Receives thunk function
M->>API: fetch("/api/users")
API-->>M: Response (JSON)
M->>D: dispatch({ type: "fulfilled", payload: data })
D->>R: Process action
R->>S: Update state
S-->>C: Re-render
Redux Thunk
A "thunk" is a programming term meaning "a delayed computation." Redux Thunk is middleware that allows you to dispatch functions instead of actions.
Built Into RTK
Redux Toolkit's configureStore includes Redux Thunk middleware by default. No additional installation needed.
import { configureStore } from "@reduxjs/toolkit";
const store = configureStore({
reducer: rootReducer
// Thunk middleware is automatically included
});
Writing a Thunk Manually
// Thunk action creator
const fetchUsers = () => {
// Returns a function that receives dispatch and getState
return async (dispatch, getState) => {
dispatch({ type: "users/loading" });
try {
const response = await fetch("/api/users");
const data = await response.json();
dispatch({ type: "users/loaded", payload: data });
} catch (error) {
dispatch({ type: "users/error", payload: error.message });
}
};
};
// Dispatch from a component
dispatch(fetchUsers());
TypeScript version
import { AppDispatch, RootState } from "./store";
const fetchUsers = () => {
return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: "users/loading" });
try {
const response = await fetch("/api/users");
const data: User[] = await response.json();
dispatch({ type: "users/loaded", payload: data });
} catch (error) {
dispatch({
type: "users/error",
payload: error instanceof Error ? error.message : "Unknown error"
});
}
};
};
Writing this boilerplate every time is tedious. That's where createAsyncThunk comes in.
createAsyncThunk
createAsyncThunk is an RTK utility that generates thunk action creators for async operations. It automatically creates three lifecycle actions.
Basic Usage
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
// Define an async thunk
const fetchUsers = createAsyncThunk(
"users/fetchUsers", // Action type prefix
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data = await response.json();
return data; // This becomes the fulfilled action's payload
} catch (error) {
return rejectWithValue(error.message);
}
}
);
TypeScript version
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
}
const fetchUsers = createAsyncThunk<
User[], // Return type on success
void, // Argument type
{ rejectValue: string } // rejectWithValue type
>(
"users/fetchUsers",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data: User[] = await response.json();
return data;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
Auto-Generated Actions
createAsyncThunk automatically generates three action types:
| Action | Type | When |
|---|---|---|
| pending | users/fetchUsers/pending |
When async operation starts |
| fulfilled | users/fetchUsers/fulfilled |
On success |
| rejected | users/fetchUsers/rejected |
On error |
flowchart LR
subgraph Lifecycle["createAsyncThunk Lifecycle"]
direction LR
D["dispatch\n(fetchUsers())"] --> P["pending"]
P --> API["API Call"]
API -->|Success| F["fulfilled\npayload: data"]
API -->|Failure| R["rejected\npayload: error"]
end
style P fill:#f59e0b,color:#fff
style F fill:#22c55e,color:#fff
style R fill:#ef4444,color:#fff
Managing Loading State with extraReducers
Actions generated by createAsyncThunk are handled in extraReducers.
Loading State Pattern
const usersSlice = createSlice({
name: "users",
initialState: {
ids: [],
entities: {},
status: "idle", // "idle" | "loading" | "succeeded" | "failed"
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
// When using entity adapter
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload ?? "An error occurred";
});
}
});
TypeScript version
import { createSlice, createEntityAdapter, EntityState } from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
}
interface UsersState extends EntityState<User, number> {
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
const usersAdapter = createEntityAdapter<User>();
const initialState: UsersState = usersAdapter.getInitialState({
status: "idle",
error: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload ?? "An error occurred";
});
}
});
Using in a Component
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchUsers } from "./usersSlice";
function UserList() {
const dispatch = useDispatch();
const users = useSelector(usersSelectors.selectAll);
const status = useSelector((state) => state.users.status);
const error = useSelector((state) => state.users.error);
useEffect(() => {
if (status === "idle") {
dispatch(fetchUsers());
}
}, [status, dispatch]);
if (status === "loading") {
return <div className="spinner">Loading...</div>;
}
if (status === "failed") {
return <div className="error">Error: {error}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
Practical Example: CRUD Operations
Creating a Resource
const createUser = createAsyncThunk(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
Updating a Resource
const updateUser = createAsyncThunk(
"users/updateUser",
async ({ id, changes }, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
if (!response.ok) {
return rejectWithValue("Update failed");
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
Deleting a Resource
const deleteUser = createAsyncThunk(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) {
return rejectWithValue("Delete failed");
}
return userId; // Return the deleted ID
} catch (error) {
return rejectWithValue(error.message);
}
}
);
Handling Everything in extraReducers
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState({
status: "idle",
error: null
}),
reducers: {},
extraReducers: (builder) => {
builder
// Fetch
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload;
})
// Create
.addCase(createUser.fulfilled, (state, action) => {
usersAdapter.addOne(state, action.payload);
})
// Update
.addCase(updateUser.fulfilled, (state, action) => {
usersAdapter.upsertOne(state, action.payload);
})
// Delete
.addCase(deleteUser.fulfilled, (state, action) => {
usersAdapter.removeOne(state, action.payload);
});
}
});
TypeScript version
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserPayload {
name: string;
email: string;
}
interface UpdateUserPayload {
id: number;
changes: Partial<Omit<User, "id">>;
}
const createUser = createAsyncThunk<User, CreateUserPayload, { rejectValue: string }>(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message);
}
return (await response.json()) as User;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
const updateUser = createAsyncThunk<User, UpdateUserPayload, { rejectValue: string }>(
"users/updateUser",
async ({ id, changes }, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
if (!response.ok) {
return rejectWithValue("Update failed");
}
return (await response.json()) as User;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
const deleteUser = createAsyncThunk<number, number, { rejectValue: string }>(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) {
return rejectWithValue("Delete failed");
}
return userId;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
Error Handling Patterns
Custom Errors with rejectWithValue
By default, when an exception is thrown inside a thunk, serializing the error can be problematic. Using rejectWithValue lets you return structured error information.
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
// Return structured error info from the API
const errorData = await response.json();
return rejectWithValue({
status: response.status,
message: errorData.message || "Unknown error",
details: errorData.errors || []
});
}
return await response.json();
} catch (error) {
// Network errors, etc.
return rejectWithValue({
status: 0,
message: "Network error",
details: []
});
}
}
);
// Using in a reducer
builder.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
if (action.payload) {
// Error returned via rejectWithValue
state.error = action.payload.message;
state.errorDetails = action.payload.details;
} else {
// Unexpected error
state.error = action.error.message;
}
});
Resetting Error State
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
errorCleared(state) {
state.error = null;
state.status = "idle";
}
},
extraReducers: (builder) => {
// ...
}
});
// Reset error when retrying in a component
function UserList() {
const dispatch = useDispatch();
const error = useSelector((state) => state.users.error);
const handleRetry = () => {
dispatch(errorCleared());
dispatch(fetchUsers());
};
if (error) {
return (
<div>
<p>Error: {error}</p>
<button onClick={handleRetry}>Retry</button>
</div>
);
}
}
Cancellation
Cancelling with AbortController
createAsyncThunk supports cancellation via AbortController.
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { signal }) => {
// signal is automatically provided as AbortController.signal
const response = await fetch("/api/users", { signal });
return await response.json();
}
);
// Cancel in a component
function UserList() {
const dispatch = useDispatch();
useEffect(() => {
const promise = dispatch(fetchUsers());
// Cancel the request on cleanup
return () => {
promise.abort();
};
}, [dispatch]);
}
Conditional Execution with the condition Option
Prevent duplicate requests using the condition option.
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async () => {
const response = await fetch("/api/users");
return await response.json();
},
{
// Don't execute if already loading
condition: (_, { getState }) => {
const { status } = getState().users;
if (status === "loading") {
return false; // Cancel the thunk
}
}
}
);
TypeScript version
const fetchUsers = createAsyncThunk<
User[],
void,
{ state: RootState; rejectValue: string }
>(
"users/fetchUsers",
async (_, { signal, rejectWithValue }) => {
try {
const response = await fetch("/api/users", { signal });
if (!response.ok) {
return rejectWithValue("Failed to fetch");
}
return (await response.json()) as User[];
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return rejectWithValue("Request cancelled");
}
return rejectWithValue("Network error");
}
},
{
condition: (_, { getState }) => {
const { status } = getState().users;
return status !== "loading";
}
}
);
Loading State Pattern
Use this standard pattern for managing async state:
{
status: "idle" | "loading" | "succeeded" | "failed",
error: string | null
}
stateDiagram-v2
[*] --> idle
idle --> loading : dispatch(fetchData())
loading --> succeeded : fulfilled
loading --> failed : rejected
succeeded --> loading : Re-fetch
failed --> loading : Retry
failed --> idle : errorCleared
Multiple Async Operations
const initialState = usersAdapter.getInitialState({
fetchStatus: "idle",
createStatus: "idle",
updateStatus: "idle",
deleteStatus: "idle",
error: null
});
// Or group by operation
const initialState2 = usersAdapter.getInitialState({
operations: {
fetch: { status: "idle", error: null },
create: { status: "idle", error: null },
update: { status: "idle", error: null },
delete: { status: "idle", error: null }
}
});
Middleware Comparison: Thunk vs Saga vs Observable
| Feature | Redux Thunk | Redux Saga | Redux Observable |
|---|---|---|---|
| Concept | Dispatch functions | Generator functions | RxJS Observables |
| Learning curve | Low | High | High |
| Built into RTK | Yes | No | No |
| Testability | Average | High | High |
| Complex async flows | Limited | Excellent | Excellent |
| Bundle size | Minimal | Medium | Large |
| Recommended for | Most apps | Complex async flows | Reactive processing |
When using RTK, start with Thunk. It's sufficient for the majority of applications. If you need more complex async flows, consider RTK Query (covered on Day 8).
Full Example: User Management Screen
Here's a complete example combining everything you've learned.
// features/users/usersSlice.js
import { createSlice, createAsyncThunk, createEntityAdapter } from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter({
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
// Async Thunks
export const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { rejectWithValue, signal }) => {
try {
const response = await fetch("/api/users", { signal });
if (!response.ok) throw new Error("Failed to fetch");
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
},
{
condition: (_, { getState }) => {
return getState().users.status !== "loading";
}
}
);
export const createUser = createAsyncThunk(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const err = await response.json();
return rejectWithValue(err.message);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const deleteUser = createAsyncThunk(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) return rejectWithValue("Delete failed");
return userId;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState({
status: "idle",
error: null
}),
reducers: {
errorCleared(state) {
state.error = null;
state.status = "idle";
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload;
})
.addCase(createUser.fulfilled, usersAdapter.addOne)
.addCase(deleteUser.fulfilled, usersAdapter.removeOne);
}
});
// Selectors
export const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
export const { errorCleared } = usersSlice.actions;
export default usersSlice.reducer;
// components/UserManagement.jsx
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
fetchUsers,
createUser,
deleteUser,
errorCleared,
usersSelectors
} from "../features/users/usersSlice";
function UserManagement() {
const dispatch = useDispatch();
const users = useSelector(usersSelectors.selectAll);
const status = useSelector((state) => state.users.status);
const error = useSelector((state) => state.users.error);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
useEffect(() => {
const promise = dispatch(fetchUsers());
return () => promise.abort();
}, [dispatch]);
const handleCreate = async (e) => {
e.preventDefault();
await dispatch(createUser({ name, email }));
setName("");
setEmail("");
};
const handleDelete = (userId) => {
if (window.confirm("Are you sure you want to delete this user?")) {
dispatch(deleteUser(userId));
}
};
const handleRetry = () => {
dispatch(errorCleared());
dispatch(fetchUsers());
};
if (status === "loading") {
return <div>Loading...</div>;
}
if (status === "failed") {
return (
<div>
<p>Error: {error}</p>
<button onClick={handleRetry}>Retry</button>
</div>
);
}
return (
<div>
<h1>User Management</h1>
<form onSubmit={handleCreate}>
<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">Add</button>
</form>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
<button onClick={() => handleDelete(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default UserManagement;
Summary
| Concept | Description |
|---|---|
| Middleware | Intercepts between dispatch and reducer |
| Redux Thunk | Middleware that allows dispatching functions (built into RTK) |
createAsyncThunk |
Automatically manages pending / fulfilled / rejected lifecycle |
extraReducers |
Handles externally defined actions (thunks, etc.) |
| Loading state | status: "idle" | "loading" | "succeeded" | "failed" |
rejectWithValue |
Returns structured error information |
signal |
Request cancellation via AbortController |
condition |
Prevents duplicate requests |
Exercises
Exercise 1: Basic createAsyncThunk
Create createAsyncThunk instances for the following API endpoints:
GET /api/todos-- Fetch all todosPOST /api/todos-- Create a new todo (body:{ title, completed })PUT /api/todos/:id-- Update a todo (body:{ title, completed })DELETE /api/todos/:id-- Delete a todo
Implement proper error handling (rejectWithValue) for each thunk.
Exercise 2: Loading State Management
Using the thunks from Exercise 1, implement a slice that meets these requirements:
- Use
createEntityAdapterto manage entities - Track loading state individually for each operation (fetch, create, update, delete)
- A selector for displaying error messages to the user
- An error reset feature
Exercise 3: Cancellation and Conditional Execution
Write code to handle the following scenario:
- A search form that calls the API on every keystroke
- When a new request is made, cancel the previous one
- Prevent duplicate requests for the same search term
- Implement debouncing (300ms)
Hint: Combine useEffect cleanup functions with promise.abort().