Reducers are synchronous and pure, so a fetch cannot live in one. A thunk is the escape hatch: a function you pass to dispatch instead of an action object. The thunk middleware — installed by configureStore with no setup — calls it with (dispatch, getState, extraArgument), so the function can await, branch on the current state, and dispatch as many plain actions as it needs.
const checkout = (state = { status: 'idle', order: null }, action) =>
action.type === 'checkout/started' ? { ...state, status: 'sending' }
: action.type === 'checkout/done' ? { status: 'paid', order: action.payload }
: state;
const store = configureStore({ reducer: checkout });
const submitOrder = (total) => async (dispatch, getState) => { // a thunk
if (getState().status === 'sending') return 'ignored'; // no double submit
dispatch({ type: 'checkout/started' });
await new Promise(r => setTimeout(r, 200)); // stands in for POST /api/orders
dispatch({ type: 'checkout/done', payload: { id: 'A-17', total } });
return getState().order; // dispatch() returns the thunk's promise
};
const first = store.dispatch(submitOrder(27));
console.log('second dispatch ->', await store.dispatch(submitOrder(27)));
console.log('first dispatch ->', await first);second dispatch -> ignored
first dispatch -> { id: 'A-17', total: 27 }A thunk's return value comes back from dispatch — here a promise, so the caller can await a save and then navigate. The duplicate submit is rejected by reading getState(), a check that belongs in the thunk because the component is not the only thing that can start a checkout. The third argument, configured once with getDefaultMiddleware({ thunk: { extraArgument: api } }), lets every thunk call api.post(...) without importing anything — that is how you swap in a fake client for tests. What thunks lack is structure: every one that loads data writes the same started/succeeded/failed trio by hand, which is what createAsyncThunk replaces.