Async Thunk is an Antipattern

Async Thunk is an Antipattern

I will stand firmly by that statement. I realize thunk has become a sacred cow, now that it has been reified into redux toolkit but I'm going to make the case that doing so was a poor choice. Note: this post assumes a familiarity with the redux middleware api.

Unidirectional data

To understand redux, you have to understand that organizing your application around action dispatch and reduced state is a win because when data flows in one direction, it is far easier to reason about. Thunk, for all its convenience flies in the face of this principle and obviates much of the advantage of using redux to begin with.

A typical thunk based library defines async action creators like this one:

const fetchHorses = (horseId: number) => (dispatch, getState, api) => {
  dispatch(loading('horse', horseId));
  return api
     .horse(horseId)
     .then((horse: Horse) => dispatch(fetchedHorse(horse)))
}

(For simplicity, we’ll assume that fetchedHorse also reverts the loading state in the reducer.)

What's wrong with this action creator? The logic is simple but the devil is in the details. Since it's not a pure function, it can't be tested in isolation. Not only that, but it violates the principle of unidirectional data flow—data enters the function not only from the arguments, but from the outside world. In this case, the horseApi.

You'll need to mock that api server if you want to include fetchHorses in a unit test. This requirement to mock the outside world has a way of infecting every component that includes a a call to this action creator.

Quite a lot of complication for just a few lines of code.

There’s a better way

Async thunk, as it turns out, is itself implemented in a few lines of middleware. This is the entirety of the logic in Redux Thunk, with the comments and construction function wrapper removed:

({ dispatch, getState }) => next => action => {
      if (typeof action === 'function') {
        return action(dispatch, getState, extraArgument)
      }
      return next(action)
    }
  return middleware
}

See for yourself. Middleware is powerful enough to subvert the entire purpose of redux in just a few lines of code.

What if instead of enabling asynchronous logic in our action creators, we added middleware to intercept plain actions and dispatch them to our horse api?

const horseApi: Middleware = state => next => action => {
  next(action); // always forward the action
  if (!isHorseApiAction(action)) {
    return;
  }
     
  store.dispatch(loading());

  const horse = await api.horse(toArguments(action));

  store.dispatch(fetchedHorse(horse));
}

Looks like we’re still using bidirectional data—BUT—outside this piece of middleware, the code is still unidirectional. Our action creators no longer need to handle asynchronous calls or any logic at all, really.

Generalizing the api call

What happens when we need to incorporate the HorseSizedRat api and the PotatoCarvedToLookLikeAHorseService and so on? Assuming these are all REST services, we can write middleware to handle http requests.

type FetchParams = RequestInit;
const restRequest = (origin: Action, params: FetchParams) => ({
  type: 'rest/request',
  payload: params,
  meta: { origin },
});

const restResponse = (origin: Action, response: unknown) => ({
  type 'rest/response',
  payload: response,
  meta: { origin },
});

const restError = (origin: Action, error: Error) => ({
  type: 'rest/error',
  payload: error,
  meta: { origin },
});

const rest: Middleware = state => next => action => {
   next(action);

   if (!isRestAction(action)) {
     return;
   } 

   if (action.type === 'rest/request') {
     try {
        const response = await fetch(action.payload);
        const parsed   = await response.json();

        store.dispatch(restResponse(action.meta.origin, parsed));
     } catch (e) {
        store.dispatch(restError(action.meta.origin, e);
     }
   }
}

Now our horse api middleware can handle the incoming (unidirectional) data as it comes:

const horseApi: Middleware = state => next => action => {
  next(action);
  
  if (!isHorseAction(action) {
     return;
  }  

  if (isRestResponse(action) && isHorseAction(action.meta.origin)) {
     switch (action.meta.origin.type) {
        case 'horse/fetch': 
           store.dispatch(fetchedHorse(action.payload));
           break;
        case 'horse/delete':
           store.dispatch(horseDeleted(action.payload));
           break;
        //...
     }
    return;
  }

  if (isHorseApiCall(action)) {
     const params = getHorseParams(action); //
     store.dispatch(restRequest(action, params));
     return;
  }
}

The horse api can expand, and we can add all sorts of other kinds of rest based api calls but there will only ever be one place where a bi-directional async network request happens in our code. Not only that, but the code for the request is hardly more than a forward to the built in fetch api so there’s not much that needs to be tested.

However, the logic of request construction lives inside pure functions that are easily testable with a range of inputs. Good functional apis keep their side effects isolated. Middleware gives us a place to put side effects in an application that uses Redux to handle its state.

Then why is thunk EVERYWHERE?

I blame this on the tutorial effect. Explaining async code in a redux tutorial is very easy if you use thunk as an example and hand-wave away that "you really should look into other ways to handle this." Over time, those default examples become canonized. Thunk is a platonic example of the way engineering techniques suffer from Cargo Culting.

Subscribe to PFA INC / Scotty Weeks / Software Consulting

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe