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.
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
// 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:
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
updateUsersucceeds, nothing tellsfetchUserto refetch. - No GC — the store grows monotonically.
Hand-rolled caching with thunks
You have to add it yourself. The common pattern:
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:
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
| Concern | Thunk | RTK Query |
|---|---|---|
| Fetch + reducer wiring | Hand-written | Generated from endpoint definition |
| Loading/error state | Reducer cases | Hook return |
| Cache by query args | Manual | Automatic |
| Dedup | Manual | Automatic |
| Invalidation | Manual | Tag-based |
| Polling / refetch | Manual setInterval | Built-in |
| Refetch on focus | Manual | Built-in |
| Optimistic updates | Manual rollback | onQueryStarted |
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).