skip to content

Why can't a plain Redux store run async logic on its own, and how does redux-thunk let you dispatch an async fetch?

level: juniorimportance: must knowfreq 74%

answer

  1. dispatch only takes plain objects
  2. reducers must stay synchronous
  3. a function instead of an object
  4. dispatch, getState, extra argument

basics

~20 s

The core dispatch accepts only plain objects and reducers must stay synchronous and pure, so async work needs middleware; redux-thunk lets you dispatch a function that receives dispatch and getState and dispatches plain actions as the request progresses.

solid answer

~40 s

The base store's `dispatch` throws unless the action is a plain object, and reducers must return the next state synchronously without side effects, so there is nowhere in the core to wait for a request. redux-thunk is a tiny middleware: if the dispatched value is a function, it calls it with `(dispatch, getState, extraArgument)` and returns whatever the function returns; otherwise it passes the action to `next`. So `fetchUser(id)` returns an async function that dispatches `user/fetchStarted`, awaits the request, and dispatches `user/fetchSucceeded` or `user/fetchFailed`. Because the thunk's return value is returned from `dispatch`, callers can `await dispatch(fetchUser(id))`. In redux-thunk 3 the middleware is the named export `thunk`, and `withExtraArgument(api)` injects a dependency such as an API client. Redux Toolkit's `configureStore` installs it by default.

code

ts · 25 lines
ts
import { applyMiddleware, combineReducers, legacy_createStore as createStore, type UnknownAction } from 'redux'
import { thunk, type ThunkAction } from 'redux-thunk'
import { userReducer } from './userReducer'

const rootReducer = combineReducers({ user: userReducer })
type RootState = ReturnType<typeof rootReducer>
type AppThunk<R = void> = ThunkAction<R, RootState, undefined, UnknownAction>

export const fetchUser = (id: string): AppThunk<Promise<void>> =>
  async (dispatch, getState) => {
    if (getState().user.status === 'loading') return // already in flight
    dispatch({ type: 'user/fetchStarted' })
    try {
      const res = await fetch(`/api/users/${id}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      dispatch({ type: 'user/fetchSucceeded', payload: await res.json() })
    } catch (err) {
      dispatch({ type: 'user/fetchFailed', payload: String(err) })
    }
  }

export const store = createStore(rootReducer, applyMiddleware(thunk))

// Caller: dispatch returns the thunk's promise
await store.dispatch(fetchUser('42'))

go deeper

for a junior

Explain that dispatch only accepts plain objects, and that redux-thunk lets you dispatch a function that dispatches started, succeeded and failed actions.

for a middle

Describe the middleware itself: a function gets dispatch, getState and the extra argument and its return value becomes dispatch's return; otherwise next(action).

for a senior

Show production concerns: guards against duplicate requests, error actions, stale state after await, injecting the API client, and when thunks stop scaling.

for a principal

Judge when hand-written fetch thunks should give way to a dedicated data-fetching layer, and what that migration costs a team.

## Why the core cannot do it Two rules in the Redux core leave no room for async work: - **`dispatch` accepts only plain objects.** Dispatching a function or a Promise to a store without middleware throws: "Actions must be plain objects... You may need to add middleware to your store setup to handle dispatching other values, such as 'redux-thunk' to handle dispatching functions." - **Reducers are synchronous and pure.** A reducer must return the next state immediately, from its arguments alone. It cannot await a response, and it must not start a request. So a data fetch has to happen **outside** the reducer and **around** dispatch. That is precisely where middleware sits. ## What a thunk is A **thunk** is a function that wraps work to be done later. In Redux it means: *instead of dispatching an action object, dispatch a function that will do some work and dispatch real actions itself*. A **thunk action creator** is a function that returns such a function, like `fetchUser(id)`. ## The whole middleware The core of the redux-thunk source, lightly simplified: ```ts ({ dispatch, getState }) => next => action => typeof action === 'function' ? action(dispatch, getState, extraArgument) : next(action) ``` 1. If the dispatched value is a **function**, call it with `dispatch`, `getState` and the optional **extra argument**, and **return its result**. 2. Otherwise pass it to **`next`**, so plain actions carry on to the reducer. The function never reaches the reducer, which is also why the Redux style guide allows non-serializable values in actions that middleware intercepts. ## Writing the async fetch The standard pattern dispatches plain actions at each stage of the request: 1. **Guard** using `getState()`: skip if a request is already in flight or the data is fresh. 2. **Dispatch "started"** so reducers can set `status: 'loading'`. 3. **Await** the request. 4. **Dispatch "succeeded"** with the data, or **"failed"** with the error. 5. **Return** a promise, which the `async` function does automatically, so callers can await completion. Reducers handle only the plain actions: they never see the function or the promise. ## Details worth knowing | Detail | Behaviour | |---|---| | Thunk arguments | `(dispatch, getState, extraArgument)` | | `dispatch` inside a thunk | The fully composed dispatch, so it can dispatch other thunks too | | Return value of `dispatch(thunk)` | Whatever the thunk returns, often a Promise | | Extra argument | Set with `withExtraArgument(value)`; useful for injecting an API client or mocks in tests | | redux-thunk 3 import | `import { thunk, withExtraArgument } from 'redux-thunk'`, with no default export | | Redux Toolkit | `configureStore` adds the thunk middleware by default | ## Where thunks fit and where they do not - **Good for**: imperative logic that needs `dispatch` or `getState`, such as request lifecycles, conditional dispatching, or sequencing a few steps. The Redux style guide recommends thunks for this. - **Weak at**: reacting to actions dispatched elsewhere, cancelling in-flight work, or long-running background flows. A thunk runs because someone dispatched it, not because an action happened. - **Not for server caching at scale**: hand-rolled fetch thunks re-implement loading flags, deduplication and cache invalidation. Redux's docs recommend a dedicated data-fetching layer for that. ## Testing a thunk A thunk is just a function, so it can be tested without a store: 1. Call `fetchUser('42')` to get the inner function. 2. Call that function with a **mock `dispatch`** that records its calls, a **`getState` stub** returning a chosen state, and, if you use one, a fake API as the **extra argument**. 3. `await` it, then assert the recorded actions: started then succeeded, or started then failed. Alternatively, create a real store with the real reducers and the thunk middleware, dispatch the thunk, and assert on the resulting state. That style tests behaviour rather than the exact sequence of actions, so it survives refactors better. ## Common mistakes - **Forgetting to install the middleware** with the core API. The dispatch of a function then throws the "plain objects" error above. - **Mutating state from a thunk.** Thunks read state with `getState()` but still change it only by dispatching actions. - **Swallowing errors.** Catch the error and dispatch a "failed" action; do not let the promise reject unhandled, and do not leave the status stuck at loading. - **Stale reads after await.** Call `getState()` again after an `await` if you need current state; the value read before the request may be outdated.

  • Why does a thunk receive dispatch rather than next?
    The thunk middleware passes the composed dispatch from its store API. Anything the thunk dispatches, another thunk or a plain action, therefore goes through the whole middleware chain from the start, so nested thunks work and every middleware, loggers included, sees the plain actions.
  • What is the extra argument for, and how do you set it in redux-thunk 3?
    It injects a dependency, typically an API client, as the third argument of every thunk, so thunks do not import it directly and tests can pass a fake. With the core API use applyMiddleware(withExtraArgument(api)); with Redux Toolkit, set it through configureStore's thunk middleware options.
  • How can a component wait for a thunk to finish?
    dispatch returns whatever the thunk returns, so an async thunk returns a promise: await dispatch(fetchUser(id)). Resolution means the thunk finished, not that it succeeded, unless the thunk rethrows or returns a result the caller checks.

saying these in an interview costs you the question

  • Reducers can be async functions if they return a Promise of state
  • redux-thunk sends the function through to the reducer
  • dispatch always returns the action, even for thunks
  • In redux-thunk 3 you import the default export: import thunk from 'redux-thunk'
  • Thunks may update state directly using the getState result