Skip to main content

⏳ Asynchronous Operations in Redux

Reducers must be pure and synchronous β€” yet real apps constantly fetch, save, and wait on servers. This lesson resolves that tension: you'll learn where async logic actually belongs, how thunks and createAsyncThunk track loading and errors, and how RTK Query removes most of the boilerplate entirely.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why reducers can't be async and where side effects belong instead
  • Understand what middleware is and how Redux Thunk enables async actions
  • Model any request with the pending / fulfilled / rejected state pattern
  • Write data-fetching logic with createAsyncThunk and handle it in extraReducers
  • Recognize when RTK Query is the better tool for server state

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a "fetch users" feature with full loading and error handling.

In This Lesson

Why Reducers Can't Be Async

Redux's whole promise β€” predictability β€” rests on reducers being pure functions: same inputs, same output, no side effects. A network request is the opposite of pure. It takes an unknown amount of time, might fail, and returns different data every call. If you tried to fetch inside a reducer, you'd break determinism and time-travel debugging, and the reducer would return before the data ever arrived.

πŸ’‘ An analogy: A reducer is a vending machine β€” insert a coin (action), get a snack (new state) instantly and identically every time. A network request is like ordering delivery: you place the order, wait, and it might arrive late or not at all. You don't turn the vending machine into a delivery service; you put the ordering logic somewhere else and feed the result back into the machine.

So async work lives outside reducers. When it finishes, it dispatches plain, synchronous actions carrying the results β€” and those the reducers handle normally. The tool that lets an action creator do asynchronous work before dispatching is middleware.

Middleware & Redux Thunk

Middleware sits between the moment you dispatch something and the moment it reaches the reducer. It can inspect, delay, transform, or intercept dispatched values β€” the perfect hook for side effects like logging or API calls.

flowchart LR A[dispatch] --> B[Middleware] B --> C[Reducer] C --> D[Store] B -.->|async work| E[API] E -.->|result| B

Redux Thunk is the standard middleware for async logic β€” and it's included and enabled by default in Redux Toolkit's configureStore. Normally you can only dispatch plain object actions. Thunk teaches the store one new trick: you may also dispatch a function. When you do, Thunk calls that function with dispatch and getState, letting it run async code and dispatch real actions whenever it likes.

// A "thunk" is just an action creator that returns a function
function fetchUsers() {
  return async (dispatch, getState) => {
    dispatch({ type: 'users/loading' });
    try {
      const res = await fetch('/api/users');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const users = await res.json();
      dispatch({ type: 'users/loaded', payload: users });
    } catch (err) {
      dispatch({ type: 'users/failed', payload: err.message });
    }
  };
}

// Dispatched the same way as any action
dispatch(fetchUsers());

πŸ“– Key Terms

Middleware: code that runs on every dispatched action before it reaches the reducer.

Thunk: an action creator that returns a function (instead of an object) so it can run async logic and dispatch later.

Side effect: anything that reaches outside the function β€” network calls, timers, randomness, logging.

The Request Lifecycle

Notice the pattern in the thunk above: it dispatches three kinds of action. This is universal β€” every async request moves through the same three states, and modelling all three is what gives users good loading spinners and error messages.

The three states of an async request A request starts in pending, then resolves to either fulfilled with data or rejected with an error. pending show spinner fulfilled store the data rejected show error success failure
Figure 1 β€” Every async request is either in flight (pending), done (fulfilled), or failed (rejected). Track this in your state as a status field plus an error.

A typical slice of state for a fetched resource therefore looks like this:

const initialState = {
  items: [],
  status: 'idle',   // 'idle' | 'loading' | 'succeeded' | 'failed'
  error: null
};

createAsyncThunk

Writing those three actions by hand for every request gets repetitive. Redux Toolkit's createAsyncThunk does it for you. You give it a base action type and an async "payload creator" function; it returns a thunk that automatically dispatches pending, then either fulfilled (with the returned value) or rejected (with the error).

import { createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',                 // base type -> users/fetchUsers/pending, /fulfilled, /rejected
  async (_arg, thunkAPI) => {
    const res = await fetch('/api/users');
    if (!res.ok) {
      // Return a rejected value with a clean message
      return thunkAPI.rejectWithValue(`Request failed: ${res.status}`);
    }
    return res.json();                // becomes action.payload of the fulfilled action
  }
);

The payload creator receives two things: the argument you pass when dispatching (e.g. a user id), and the thunkAPI object (with dispatch, getState, rejectWithValue, signal, and more). Passing an argument is simple:

export const fetchUserById = createAsyncThunk(
  'users/fetchUserById',
  async (userId) => {
    const res = await fetch(`/api/users/${userId}`);
    return res.json();
  }
);

// dispatch(fetchUserById('u123'));

πŸ’‘ Why rejectWithValue?

By default a thrown error becomes action.error (a serialized Error). Use rejectWithValue(payload) when you want to send your own error shape β€” an API's validation messages, say β€” to the reducer via action.payload instead.

Handling It in a Slice

Because createAsyncThunk's actions are generated outside the slice, you respond to them in the slice's extraReducers field (not the normal reducers field, which is for actions the slice defines itself). The builder callback lets you add a case per lifecycle action:

import { createSlice } from '@reduxjs/toolkit';
import { fetchUsers } from './usersThunks';

const usersSlice = createSlice({
  name: 'users',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {
    // synchronous, slice-owned actions would go here
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;      // the data returned by the thunk
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload ?? action.error.message;
      });
  }
});

export default usersSlice.reducer;

Using it in a component

import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from './usersThunks';

export function UsersList() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector((state) => state.users);

  useEffect(() => {
    if (status === 'idle') dispatch(fetchUsers());
  }, [status, dispatch]);

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'failed')  return <p role="alert">Error: {error}</p>;

  return (
    <ul>
      {items.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

What the user sees

1. "Loading…"        (fetchUsers/pending dispatched)
2. Ada Lovelace      (fetchUsers/fulfilled β€” list renders)
   Alan Turing
   Grace Hopper
   …or…
   "Error: Request failed: 500"   (fetchUsers/rejected)

When to Reach for RTK Query

The thunk pattern is essential to understand, but for the common case β€” "fetch data from an endpoint and cache it" β€” you'll write the same loading/error/caching code over and over. RTK Query, built into Redux Toolkit, generates all of it from a declarative endpoint definition.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  endpoints: (builder) => ({
    getUsers: builder.query({ query: () => 'users' }),
    getUserById: builder.query({ query: (id) => `users/${id}` }),
    addUser: builder.mutation({
      query: (newUser) => ({ url: 'users', method: 'POST', body: newUser })
    })
  })
});

// RTK Query generates hooks automatically
export const { useGetUsersQuery, useGetUserByIdQuery, useAddUserMutation } = api;
function UsersList() {
  const { data: users = [], isLoading, isError, error } = useGetUsersQuery();

  if (isLoading) return <p>Loading…</p>;
  if (isError)   return <p role="alert">Error: {String(error.status)}</p>;
  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

βœ… Rule of thumb

Use RTK Query for server state (fetching, caching, refetching, invalidation). Use createAsyncThunk + slices when async work feeds into client state you also manage synchronously, or for one-off side effects that don't fit the request/cache model.

Best Practices

βœ… Do❌ Don't
Keep fetch/axios calls in thunks or RTK QueryCall APIs inside a reducer
Track a status field and render loading/error statesAssume requests always succeed instantly
Handle all three of pending/fulfilled/rejectedOnly handle the happy path
Guard duplicate fetches (check status === 'idle', or use RTK Query caching)Re-fetch on every render
Use rejectWithValue for custom error payloadsSwallow errors silently

⚠️ Don't fetch in the reducer, and don't fetch on every render

Two of the most common beginner bugs: putting the request inside the reducer (breaks purity) and dispatching a fetch in a component body without an effect or guard (causes an infinite request loop). Fetch in a thunk, trigger it from useEffect, and guard against re-fetching.

Hands-on Exercise

πŸ‹οΈ Build a "Fetch Posts" Feature

Objective: Create a slice that loads posts from an API with full loading and error handling.

Instructions:

  1. Create a fetchPosts thunk with createAsyncThunk hitting https://jsonplaceholder.typicode.com/posts.
  2. Create a postsSlice with state { items: [], status: 'idle', error: null }.
  3. Handle pending, fulfilled, and rejected in extraReducers.
  4. In a PostsList component, dispatch the thunk from useEffect only when status === 'idle', and render loading, error, and success states.
  5. Bonus: add a "Retry" button that re-dispatches the thunk after a failure.
πŸ’‘ Hint

The thunk's return value becomes action.payload in the fulfilled case. For the retry button, you don't need to reset status to 'idle' β€” just call dispatch(fetchPosts()) again; the pending case will set status back to 'loading'.

βœ… Sample solution
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchPosts = createAsyncThunk(
  'posts/fetchPosts',
  async (_arg, { rejectWithValue }) => {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!res.ok) return rejectWithValue(`HTTP ${res.status}`);
    return res.json();
  }
);

const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending,   (s) => { s.status = 'loading'; s.error = null; })
      .addCase(fetchPosts.fulfilled, (s, a) => { s.status = 'succeeded'; s.items = a.payload; })
      .addCase(fetchPosts.rejected,  (s, a) => { s.status = 'failed'; s.error = a.payload ?? a.error.message; });
  }
});

export default postsSlice.reducer;

// features/posts/PostsList.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPosts } from './postsSlice';

export function PostsList() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector((s) => s.posts);

  useEffect(() => {
    if (status === 'idle') dispatch(fetchPosts());
  }, [status, dispatch]);

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'failed')
    return (
      <div role="alert">
        <p>Error: {error}</p>
        <button onClick={() => dispatch(fetchPosts())}>Retry</button>
      </div>
    );

  return <ul>{items.slice(0, 10).map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

🎯 Quick Quiz

Question 1: Why can't you put a fetch call directly inside a reducer?

Question 2: Which three action types does createAsyncThunk generate?

Question 3: Where do you respond to a createAsyncThunk's actions inside a slice?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Reducers stay pure and synchronous; async logic lives in middleware.
  • Redux Thunk (on by default in RTK) lets you dispatch functions that run async code and dispatch later.
  • Every request follows the pending β†’ fulfilled / rejected lifecycle β€” track it with a status field and an error.
  • createAsyncThunk generates those three actions; handle them in extraReducers.
  • RTK Query is the higher-level tool for fetching and caching server data with almost no boilerplate.

πŸ“š Further Reading

πŸš€ What's Next?

You've now covered Redux end to end β€” concepts, Toolkit, and async. Next the module pivots to a different framework: you'll explore Vue.js framework architecture and see how another ecosystem solves the same UI and state problems.

πŸŽ‰ Redux mastered!

Sync, structure, and async β€” you can now manage real application state with confidence.