Thoughs on Async Redux

9 minute read Published: 2020-08-31

Redux has been a go-to state management solution ever since it entered the mainstream. It takes a solid functional programming approach to the Flux Architecture. It uses a reducer and action objects to maintain state.

Although Redux is fantastic for local state, the core library does not provide a way to handle asynchronicity.

Many options exist, and I will compare three of them.

redux-thunk, redux-observable, and redux-saga

Redux Thunk

Redux thunk is the minimum viable async middleware for redux. It achieves its goals using (unsurprisingly) thunks, which are functions returned from functions. When dispatched, they look identical to regular action creators.

// fetchUser could be an action creator
// or a thunk, we don't care
dispatch(fetchUser())

The basic example of fetching a user from an api would look like this:

function fetchUser() {
  return async dispatch => {
    dispatch(fetchUserStart())
    try {
      const user = await fetch('/api/user').then(x => x.json())
      dispatch(fetchUserSuccess(user))
    } catch (error) {
      dispatch(fetchUserError(error))
    }
  }
}

This is great for most scenarios. It feels natural. In my opinion, redux-thunk should be preferred in 90% of cases. When I'm writing a redux application, I tend to keep redux-thunk around even if I adopt another async middleware later.

While redux-thunk is excellent at what it does well, it's pretty terrible where it's lacking. Things like debouncing inputs, canceling current requests, and action sequences are practically impossible to implement in a straight forward manner using thunks.

That's where the other middlewares come in!

Redux Observable

It was while writing redux-observable code that I decided to make this post. RxJS Observables are immensely powerful for async. Redux-observables brings us this power in the form of Epics.

An epic is a mapping from an action to an action. In type terms:

type AppEpic = (
  action$: Observable<Action>,
  state$: Observable<AppState>,
) => Observable<Action>

This type might look a bit weird, but let's consider how to do the basic async example using observables.

const fetchUserEpic: AppEpic = action$ =>
  action$.pipe(
    filter(fetchUserStart.match),
    switchMap(action =>
      fromPromise(fetch('/api/user').then(x => x.json())).pipe(
        map(payload => fetchUserFulfilled(payload)),
        catchError(error => of(fetchUserError(error))),
      ),
    ),
  )

This is more complicated than the redux-thunk example, in order to understand what's going on you need a working knowledge of Observables, what the operators switchMap, map, filter, and catchError do. Not to mention needing to know what an operator even is!

Compared to observables, thunks require less knowledge to use. But as discussed before, thunks don't deal well with debouncing inputs, canceling current requests, and action sequences.

Imagine a search box where as the user types, search results are shown.

This has some important constraints:

  1. we don't want to send a request on every keystroke (that would choke the server)
  2. we want to cancel in progress requests on a new keystroke because the one in progress is no longer relevant
  3. we need to make sure that the last request corresponds to the last keystroke, otherwise we might display stale data

Imagine this component:

export function RepoFetcher() {
  const dispatch = useDispatch()
  const repos = useSelector(x => x.repos)
  return (
    <>
      <input
        type="text"
        onChange={ev => dispatch(fetchReposInput(ev.currentTarget.value))}
      />
      <h3>
        {repos.error ? 'Error!' : repos.isLoading ? 'Loading...' : 'Results'}{' '}
      </h3>
      <ul>
        {repos.repos.map(x => (
          <li key={x.id}>
            <a href={x.html_url}>{x.full_name}</a>
          </li>
        ))}
      </ul>
    </>
  )
}

If we used a thunk to handle the fetchReposInput action, it might look like this:

const fetchReposInput = input => async dispatch => {
  dispatch(fetchReposStart())
  try {
    const repos = await fetch(
      'https://api.github.com/search/repositories?per_page=10&q=' +
        encodeURIComponent(input),
    ).then(x => x.json())
    dispatch(fetchReposSuccess(repos))
  } catch (error) {
    dispatch(fetchReposError(error))
  }
}

We have broken all 3 of the constraints with this code.

  1. Every keystroke triggers a request
  2. The old fetch is still alive when a new keystroke arrives
  3. There's no correlation between time of keystroke and time of response

The errors these constraint violations cause can be hard to track down as well.

Using observables, we can write code that fits within the constraints fairly easily

const searchRepos = q =>
  // utility exported by rxjs/ajax
  // it returns an Observable<JSON>
  // and uses XMLHttpRequest under the hood
  // to facilitate cancellations
  ajax.getJSON(
    `https://api.github.com/search/repositories?per_page=10&q=${encodeURIComponent(
      q,
    )}`,
  )
const fetchReposEpic = action$ =>
  action$.pipe(
    filter(fetchReposInput.match),
    debounceTime(300),
    switchMap(action =>
      of(fetchReposStart()).pipe(
        concat(
          searchRepos(action.payload).pipe(
            map(payload => fetchReposSuccess(payload.items)),
            catchError(error => of(fetchReposError(error))),
          ),
        ),
      ),
    ),
  )

Let's step through this:

This means our Epic will return in order:

  1. fetchReposStart()
  2. fetchReposSuccess(payload.items) | fetchReposError(error)

switchMap has properties that help us adhere to our constraints. When a new input action is dispatched while we're still fetching, it automatically stops subscribing to the current observable and cancels it.

Since we're "just" using RxJS we also gain access to a whole host of operators.

There are literally over a hundred operators like these. So it's very likely There's an operator for that

Thoughts on Redux Observable

I think observables have some benefits over thunks in cases where

  1. You're doing something more complicated than a set and forget async fetch
  2. You already know observables, or are willing to put in the effort to learn them, and teach your team.

Redux Saga

Redux saga is the final middleware we'll be looking at here.

Redux saga aims to make side effects more natural to manage, more efficient to execute, better at handling failures, and easy to test.

It does this while also maintaining an imperative syntax.

Let's take a look at how we would implement the observable example using sagas.

function* fetchRepos(action) {
  yield delay(300)
  yield put(fetchReposStart())
  try {
    const response = yield call(Api.fetchRepos, action.payload)
    yield put(fetchReposSuccess(response.items))
  } catch (error) {
    yield put(fetchReposError(error))
  }
}
function* watchFetchReposInput() {
  yield takeLatest(fetchReposInput.match, fetchRepos)
}

If you squint a bit, the fetchRepos method almost looks familiar. It's the same structure as the basic async thunk. Swap out put for dispatch, remove the yields, and we're there! There's some added noise, but in return, we get great benefits.

takeLatest also handles cancelling in progress effects.

A more convoluted example

For a basic async thunk, you do not need sagas. Where sagas shine are action sequences, especially with dependent actions.

Imagine a card game, where before you can play a card, a modal should be displayed where you pick a mana distribution to pay for the card (think two blue, three white).

The sequence of actions would be

      Request Play Card
              |
        Show Mana Modal
          /          \
         /            \
        /              \
Close Mana Modal    Pay Mana
                        |
                    Play Card

To be clear this example is not something you need sagas for, to illustrate let's look at how we would implement this sequence using observables.

export const payMana = (cost, action$, onPayAction) =>
  of(manaModalSlice.actions.showModal(cost)).pipe(
    concat(
      action$.pipe(
        takeUntil(
          actions$.pipe(filter(manaModalSlice.actions.closeModal.match)),
        ),
        filter(manaSlice.actions.payMana.match),
        mapTo(onPayAction),
      ),
    ),
  )

export const rootEpic = (action$, state$) =>
  action$.pipe(
    filter(requestPlayCard.match),
    switchMap(action => {
      const id = action.payload.id
      const card = cardSelectors.selectById(id)(state$.value)
      return payMana(
        card.manaCost,
        action$,
        playerSlice.actions.playCard({ id }),
      )
    }),
  )

This is very declarative. A gripe I've had with redux-observable is just how unintuitive it is to create dependent action sequences. It always requires a flatMap, and this amount of code is for an event series with only one branch.

How would we handle this use case with a saga?

function* payMana(cost) {
  yield put(manaModalSlice.actions.showModal(cost))
  // an infinite while loop is okay, because we know
  // that the only way to get out of the modal
  // is through the two actions handled in the loop
  while (true) {
    const action = yield take()
    if (manaSlice.actions.payMana.match(action)) {
      return true
    }
    if (manaModalSlice.actions.closeManaModal.match(action)) {
      return false
    }
  }
}

function* playCard(action) {
  const id = action.payload.id
  const card = yield select(cardSelectors.selectById(id))
  const isPayed = yield call(getManaPayment, card.manaCost)
  if (isPayed) {
    yield put(playerSlice.actions.playCard({ id }))
  }
}

function* watchRequestPlayCard() {
  yield takeEvery(requestPlayCard.match, playCard)
}

In my opinion, this code is easier to follow than the Observable example. I can read through the code step by step to get an understanding of what's going on.

This sort of understanding is harder to come by in the Observable version. I believe this is because in that example we need an understanding of RxJS operators to know what's going on. With sagas, we only require an understanding of JavaScript language primitives.

I believe, redux-saga is to redux-observable what async/await is to Promises. It's a really great syntax for declaring side effects in an imperative way. All the heavy lifting is handled by redux-saga: waiting, dispatching, and so on. We just yield descriptions of what should be done making sagas inherently testable.

Conclusion

The argument for redux-observable is that it's just RxJS, which is honestly fantastic. RxJS has been a godsend for reactive programming and async in general. But when it comes to complex dependent sequences the syntax sagas provide fits better.

I would much rather onboard a newbie to a codebase with sagas than observables. That being said I have a few rules of thumb to guide me towards the correct async middleware for a given scenario.

After using both sagas and observables, I'll stick with sagas for now.

If you already know RxJS, and need an async middleware, I think redux-observable is fantastically expressive. But I think there're tradeoffs to everything, and with redux-observable I felt more constrained than with sagas. Furthermore, it was less difficult for me to explain to others what was going on in a saga than an observable, which to me, weighs much higher than terse, meaning dense, code.