Back to React
React
medium
mid

How do thunk based API calls work in Redux and how does caching behave?

Redux thunk = action creators return a function (thunk) instead of an action object. The function gets (dispatch, getState), does async work (fetch), dispatches start/success/failure actions. Caching is manual: check getState() for existing data, skip the call if fresh. No built-in dedup, TTL, or invalidation. RTK Query is the modern replacement — it gives you the cache layer thunks don't have.

7 min read·~5 min to think through

Redux thunks are a middleware-enabled extension that lets action creators be functions instead of plain action objects. They became the canonical Redux async pattern before RTK Query existed.

What a thunk looks like

js
// classic thunk
const fetchUser = (id) => async (dispatch, getState) => {
  dispatch({ type: 'user/fetch/pending', id });
  try {
    const res = await fetch(`/api/users/${id}`);
    const data = await res.json();
    dispatch({ type: 'user/fetch/fulfilled', id, data });
  } catch (err) {
    dispatch({ type: 'user/fetch/rejected', id, error: err.message });
  }
};

dispatch(fetchUser(42));

The thunk middleware sees the dispatched function and calls it with (dispatch, getState).

Redux Toolkit's createAsyncThunk

The modern, less verbose version:

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

const fetchUser = createAsyncThunk(
  'user/fetch',
  async (id, { rejectWithValue }) => {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return rejectWithValue(res.status);
    return res.json();
  }
);

// reducer gets pending/fulfilled/rejected automatically
const slice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (b) => {
    b.addCase(fetchUser.pending,   s => { s.loading = true; });
    b.addCase(fetchUser.fulfilled, (s, a) => { s.data = a.payload; s.loading = false; });
    b.addCase(fetchUser.rejected,  (s, a) => { s.error = a.payload; s.loading = false; });
  },
});

Caching: what thunks DON'T do

A vanilla thunk:

  • No dedup — two components calling dispatch(fetchUser(42)) simultaneously trigger two requests.
  • No TTL — the data sits in the store forever; you don't know if it's stale.
  • No invalidation — when updateUser succeeds, nothing tells fetchUser to refetch.
  • No GC — the store grows monotonically.

Hand-rolled caching with thunks

You have to add it yourself. The common pattern:

js
const fetchUser = createAsyncThunk(
  'user/fetch',
  async (id, { getState, rejectWithValue }) => {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return rejectWithValue(res.status);
    return res.json();
  },
  {
    condition: (id, { getState }) => {
      const existing = getState().users.byId[id];
      // skip if already fresh
      if (existing && Date.now() - existing.fetchedAt < 60_000) return false;
    },
  }
);

condition lets the thunk bail before running based on existing state.

For dedup, track in-flight requests:

js
const inflight = new Map();
const fetchUserDeduped = (id) => async (dispatch) => {
  if (inflight.has(id)) return inflight.get(id);
  const p = dispatch(fetchUser(id)).finally(() => inflight.delete(id));
  inflight.set(id, p);
  return p;
};

That's about 30 lines of plumbing for what RTK Query and React Query give you for free.

Where thunks still make sense

  • Complex multi-step workflows: fetch X, then based on result fetch Y, then dispatch Z. RTKQ is endpoint-shaped; thunks handle arbitrary orchestrations.
  • Side effects that aren't HTTP — analytics, WebSocket subscribe/unsubscribe, telemetry.
  • Migration paths from legacy thunk codebases — you can use RTKQ for new endpoints and leave thunks for the old.

What RTKQ gives you over thunks

ConcernThunkRTK Query
Fetch + reducer wiringHand-writtenGenerated from endpoint definition
Loading/error stateReducer casesHook return
Cache by query argsManualAutomatic
DedupManualAutomatic
InvalidationManualTag-based
Polling / refetchManual setIntervalBuilt-in
Refetch on focusManualBuilt-in
Optimistic updatesManual rollbackonQueryStarted

Decision

  • New codebase with Redux? Use RTK Query.
  • Existing thunk codebase? Add RTKQ for new features; migrate hot endpoints opportunistically.
  • Need arbitrary orchestrations (not request/response)? Thunks (or sagas).
  • No Redux? React Query / SWR.

Thunks aren't deprecated, but the data-fetching use case they solved is better served by RTK Query. Today's thunk should be a side-effect workflow, not a cached HTTP call.

Follow-up questions

  • What does createAsyncThunk's condition option do?
  • How would you implement request deduplication on top of a thunk?
  • When would a saga be preferable to a thunk?
  • What's the migration path from thunks to RTK Query?

Common mistakes

  • Using thunks for everything when RTKQ would handle caching/dedup for free.
  • Forgetting condition: skipping the call when data is fresh — leading to duplicate requests.
  • Not handling rejected — silent failures, UI stuck on loading.
  • Storing huge response bodies in Redux state and never garbage-collecting.
  • Race conditions: two thunks for the same resource, last-write-wins on stale data.
  • Putting non-serializable values (Promises, Errors, class instances) into the store.

Performance considerations

  • Thunks themselves are essentially free (function call + middleware passthrough). The perf wins from moving to RTKQ are cache hits (skip the network), dedup (collapse N concurrent identical calls into 1), and normalized cache (avoid redundant re-renders on partial updates). A typical 20-endpoint app moving from thunks → RTKQ sees fewer requests and simpler components.

Edge cases

  • createAsyncThunk's rejectWithValue lets you distinguish recoverable rejections from thrown errors.
  • Aborting: createAsyncThunk supports an abort signal via the second argument's signal property.
  • Listener middleware in RTK can react to thunk completions for follow-up effects (cleaner than chained thunks).
  • DevTools time-travel works with thunks but only replays the dispatched actions, not the side-effect (the network call doesn't re-fire).
  • Thunks aren't pure — testing requires mocking dispatch and getState.

Real-world examples

  • Most Redux apps from 2018–2021 are thunk-based; the Redux docs themselves recommend RTKQ for new apps.
  • Many large internal tools still run on thunks because migrating is a large refactor.
  • Sagas are an alternative for very orchestration-heavy apps (concurrent flows, cancellation, retries).

Senior engineer discussion

Seniors recognize that 'thunks' is shorthand for 'we wired up async ourselves.' If the workload is fetch-cache-invalidate, RTKQ or React Query is strictly better. Thunks remain valuable for orchestrations and non-HTTP side effects. They also know that migrating is incremental: RTKQ coexists with thunks in the same store, so adoption can happen endpoint by endpoint.

Related questions