Skip to content
React rc state-management 5 min read

Async Logic with Thunks

Reducers in Redux must be pure and synchronous, so anything that touches the outside world — fetching from an API, reading from storage, waiting on a timer — has to live somewhere else. That “somewhere else” is a thunk: a function that Redux Toolkit’s middleware lets you dispatch instead of a plain action. createAsyncThunk wraps an async function and automatically dispatches lifecycle actions (pending, fulfilled, rejected) so your slice can track loading and error state with almost no boilerplate. This is the standard way to model server interactions when you are managing them by hand rather than with RTK Query.

Why thunks exist

A reducer answers one question: given the current state and an action, what is the next state? It cannot start a network request, because that result arrives later and out of band. Thunks bridge the gap. The redux-thunk middleware (bundled and enabled by configureStore) intercepts any dispatched function and calls it with dispatch and getState, giving you a place to run async work and dispatch real actions when it finishes.

createAsyncThunk builds on this. You give it a string prefix and an async “payload creator,” and it returns a thunk action creator plus three generated action types you can listen for in your reducers.

Defining an async thunk

The payload creator receives the argument you pass when dispatching, plus a thunkAPI object (with getState, dispatch, rejectWithValue, and an abort signal). Whatever you return becomes the fulfilled action’s payload.

// features/usersSlice.js
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk(
  'users/fetchUser',          // action type prefix
  async (userId, { rejectWithValue, signal }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal,
    });
    if (!res.ok) {
      return rejectWithValue(`Request failed with status ${res.status}`);
    }
    return await res.json();  // becomes action.payload on fulfilled
  }
);

Use rejectWithValue to send a controlled error payload into the rejected action. Without it, the thrown error is still captured, but you only get the serialized Error.message in action.error rather than your own structured value.

Handling the lifecycle in extraReducers

Thunk actions are not defined in the slice’s reducers field — they come from outside it — so you respond to them in extraReducers. The builder callback gives you addCase to match each generated action type. A typical pattern tracks three things: the data, a status string, and an error.

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entity: null,
    status: 'idle',   // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.entity = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed';
        // payload from rejectWithValue, else the thrown error message
        state.error = action.payload ?? action.error.message;
      });
  },
});

export default usersSlice.reducer;

Because createSlice runs reducers through Immer, the state.x = ... assignments above are safe — they produce a new immutable state behind the scenes.

Prefer an explicit status string over a boolean isLoading. Distinguishing idle, loading, succeeded, and failed lets the UI show an initial empty state separately from a finished-but-empty result, and avoids ambiguous in-between flags.

The three generated action types

ActionDispatched whenPayload
fetchUser.pendingThe thunk startsThe argument you passed (in action.meta.arg)
fetchUser.fulfilledThe payload creator resolvesThe resolved return value
fetchUser.rejectedIt throws or calls rejectWithValueaction.payload (from rejectWithValue) or action.error

The thunk itself also returns a promise. You can await dispatch(fetchUser(1)).unwrap() to get the resolved value directly or have it throw on rejection — handy when a component needs to react to success or failure inline.

Wiring it to a component

Components dispatch the thunk like any action creator and read the loading and error state with useSelector.

// UserCard.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUser } from './features/usersSlice';

export default function UserCard({ userId }) {
  const dispatch = useDispatch();
  const { entity, status, error } = useSelector((state) => state.users);

  useEffect(() => {
    const promise = dispatch(fetchUser(userId));
    return () => promise.abort(); // cancel in-flight request on cleanup
  }, [dispatch, userId]);

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

  return (
    <article>
      <h2>{entity.name}</h2>
      <p>{entity.email}</p>
    </article>
  );
}

Dispatching fetchUser returns a promise with an .abort() method, so the effect cleanup cancels a stale request when userId changes — the signal passed into fetch honors it.

Output:

// store state over time for userId=1
{ status: "idle",      entity: null, error: null }
{ status: "loading",   entity: null, error: null }
{ status: "succeeded", entity: { id: 1, name: "Leanne Graham", email: "..." }, error: null }

Avoiding duplicate requests

The condition option on a thunk lets you bail out before the payload creator runs — for example, skipping a refetch when data is already loading.

export const fetchUser = createAsyncThunk(
  'users/fetchUser',
  async (userId, { signal }) => {
    const res = await fetch(`/api/users/${userId}`, { signal });
    return res.json();
  },
  {
    condition: (userId, { getState }) => {
      const { status } = getState().users;
      if (status === 'loading') return false; // skip; no actions dispatched
    },
  }
);

Best practices

  • Keep all async work inside the payload creator; let extraReducers only record results so reducers stay pure and synchronous.
  • Model request state with an explicit status enum plus an error field rather than scattered booleans.
  • Use rejectWithValue to return structured, serializable error payloads instead of relying on raw thrown errors.
  • Pass the thunkAPI.signal into fetch and abort in-flight requests on unmount or argument changes to prevent race conditions.
  • Use the condition option to dedupe or short-circuit requests when data is already loading or fresh.
  • For typical CRUD data fetching with caching and invalidation, reach for RTK Query before hand-writing thunks — it eliminates most of this code.
Last updated June 14, 2026
Was this helpful?