Learn Redux in 10 DaysDay 4: Async Operations
books.chapter 4Learn Redux in 10 Days

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
  • createAsyncThunk lifecycle management (pending / fulfilled / rejected)
  • Managing loading state with extraReducers and builder.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 todos
  • POST /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:

  1. Use createEntityAdapter to manage entities
  2. Track loading state individually for each operation (fetch, create, update, delete)
  3. A selector for displaying error messages to the user
  4. An error reset feature

Exercise 3: Cancellation and Conditional Execution

Write code to handle the following scenario:

  1. A search form that calls the API on every keystroke
  2. When a new request is made, cancel the previous one
  3. Prevent duplicate requests for the same search term
  4. Implement debouncing (300ms)

Hint: Combine useEffect cleanup functions with promise.abort().