Exploring redux-sagas II: Compositions, Integration, Testing

This is the second of a two-part series. If you missed part one, go back and take a look to catch up on why and in which contexts you should be using redux-sagas.

Why should I use sagas as my glue code?

As a bit of background: after a brief stint of thunk-less React (of a couple of months), I adopted redux-thunks for the reasons outlined in the last section. It made life so much easier, allowing me to seperate my code into nice (fairly) distinct layers; React, 'glue', and Redux. It all worked pretty well; nothing cropped up to make me actively dislike thunks. In fact, when I was first reading through the (pretty good!) redux-saga documentation, it was from a viewpoint of cynicism; thunks were working, and sagas seem to be involved typing out more lines of code! They also needed to be manually 'wired together' through a series of yields. Thunks, by comparison, are just imported and called directly by the components using them, so are much less effort.

So why bother? If it's not going to make my life easier, or at least provide me with some bonus functionality as a trade off, then it's not worth it! For me, the major tipping point that pushed me into trying them out was the ease of testing sagas (more on that later). But I stayed for the easy composition, and the interesting ways in which sagas can interact with each other.

The fun of composition

One of the most powerful aspects of sagas is the ability to very easily combine various competing sagas/effects. This made situations such as cancelling multiple xhr requests much more simple than the thunk equivalent would have been. Let's have a look at a fairly common pattern: we have 3 requests for fetching all of the data we need for a given part of our application. If any of them fail, we have no interest in carrying on with the others (if you need to go out to buy more butter for the Robot, there's no point in continuing a search for a store if you failed to find your wallet). We also want the ability to cancel them (say, if the robot exploded).

Here's an example of what that saga may look like:

import {  
  call, 
  race, 
  take,
  select,
} from 'redux-saga/effects';
import {  
  fetchWalletLocation,
  fetchCarStatus,
  fetchStoreDetails,
} from './otherSagas';
import { butterTripViable } from './selectors';  
import {  
  CANCEL_REQUEST_BUTTER,
  REQUEST_BUTTER_ERROR, 
} from './constants';

function* requestButter() {  
  const { cancel, error } = yield race({
      requests: [
        call(fetchWalletLocation),
        call(fetchCarStatus),
        call(fetchStoreDetails),
      ],
      cancel: take(CANCEL_REQUEST_BUTTER),
      error: take(REQUEST_BUTTER_ERROR),
  });    

  //Do stuff if cancel or error are defined
  if (cancel) { /* stuff */ return; }
  if (error) { /* stuff */ return; }

  if (yield select(butterTripViable)) {
    //Note that you can yield to other sagas, or, if they watch for an action,
    //dispatch the action. Both methods have benefits and drawbacks, so it's up
    //to you to decide which one to use! In this case, I've no interest in any
    //result returned from the saga, so I'm dispatching to get that extra
    //'action history' logged in the redux dev tools. I tend to find that the extra
    //history is useful when chaining sagas together in this way.
    yield put({ type: COMMENCE_BUTTERY_ROAD_TRIP });
  }
}

Using a saga effect called race, we pit three effects against each other in a fight to the death. Three effects enter, only one leaves. One of them is our array of requests for loading all of our data. One is a take effect listening for an action indicating some form of error has occurred, and the other is a take effect listening for a cancel action.

When the winner of a race effect (i.e. the first one to complete) is known, the others effects are remorselessly cancelled (Note: this will not automatically cancel any xhr request). If the effect involves calling multiple other effects (like in our request property), then all of those effects will be cancelled too. In the example above that means that all three of our 'fetch' sagas will be cancelled if the cancel or error actions are dispatched.

Upon completion of the race, the race effect returns an object, where only the winning property will be populated; the others will be undefined. By destructuring the object, we can then choose to act further if certain effects were the winner. In requestButter() we're not paying attention to the request property (as the fetch sagas, in this simple example, are handling the store updates themselves), but we are interested if the saga was cancelled or failed, so they're destructured and referenced later on.

The result of this pattern is that the only thing we need to do to cancel an ongoing group of sagas is to simply dispatch the correct cancellation action! This is just an action like any other, so you are completely free to dispatch an action from a componentWillUnmount() method, or from any other part of your application, without having to pass references to the cancellation tokens of these specific requests around.

We're not quite done yet though! Cancelling a saga doesn't mean the xhr request will be cancelled too. All that the saga cancellation will do is prevent the application from doing anything useful with the response. It'd be like ordering a pizza, but being extremely impolite and getting eaten by a rogue lovecraftian horror before it gets delivered. The pizza preparation and delivery is now wasted effort (although you've got bigger issues, being digested and all that). Wouldn't it be far better if that pizza delivery would be cancelled upon your consumption, to prevent all that wasted effort and the mental scarring of the person delivering it?

Fortunately, sagas make this pretty easy too:

import {  
  call, 
  put, 
  cancelled,
} from 'redux-saga/effects';
import {  
  REQUEST_BUTTER_ERROR, 
} from './constants';
import { api } from './someApi';

function* fetchCarStatus(requestCancelToken = cancelToken()) {  
  try {
    const response = yield call(apiCall, {
      cancelToken: requestCancelToken.token,
    });
    put(someAction(someMapping(response)));
  } catch (error) {
    put({ type: REQUEST_BUTTER_ERROR, payload: error });
  } finally {
    if (yield cancelled()) {  
       requestCancelToken.cancel();
    }
  }
}

Using the finally block, we can handle any 'cleanup' functionality that will need to run when the saga completes. To figure out if the saga was cancelled (therefore avoiding unnecessary cancel token calls) redux saga provides a 'cancelled' effect. Using this handy effect, we can reliably cancel our xhr request whenever the saga is cancelled.

What's neat about this pattern is that there's no need to leak the cancel token anywhere else in your application; it is all handled within this one saga, avoiding the need to leak the xhr cancellation implementation. There's also the benefit that, should you decide you'd quite like to reuse this saga elsewhere, there's no extra setup required to enable cancellation of the xhr request. If the saga is cancelled, so is any ongoing request.

At the moment, the simplicity of request cancellation is the largest single benefit I've yielded from sagas. Considering it's a fairly common pattern, that's no small thing!

Integrating sagas

Sagas are yielded from other sagas (which is why you need to separately 'wire them up' in your application), with the 'root saga' hooking into the store's dispatch mechanism via middleware. Sagas then listen for specific action types using the take effect.

const REQUEST_BUTTER = 'my-sub-app.sagas.request-butter';

function* rootSaga() {  
  //forks allow you to perform a non-blocking call to a generator
  //or function. These sagas are still linked to the parent, though.
  //Terminating the parent will terminate all forked 'child' sagas.
  yield fork(watchRequestButter);
  yield fork(watchDoAThing);
  yield fork(watchSchtapDoingStuff);
  yield fork(watchModifySchwiftinessLevels);
}

function* watchLoadStuff() {  
  while (yield take(REQUEST_BUTTER)) {
      yield call(loadStuff);
  };
}

The reliance on actions allows you to easily trace when certain sagas are being triggered using the redux dev tools. Combined with the actions you'd usually have in your application, and some sensible prefixes, Redux dev tools transforms into a fairly detailed reference for your application's behavior history. Bugs are much easier to track down if you know at a glance which saga was the last one to run before the bug occurred!

This has an added bonus of neatly separating the sagas from the React layer. The connected React components do not need to have any awareness that a saga is going to act upon the action, they're just dispatching a message. By comparison, for thunks, the thunk itself is called by the connected component, and the thunk itself eventually dispatches some action. If, for some reason, the redux-saga layer simply disappeared from the application, the React components would still work perfectly fine - there'd just be nothing listening to the dispatched 'saga actions'.

Depending on how far you want to take sagas you can either route every action through the saga layer, including fairly standard store updates, or dispatch actions to sagas only when you would like to perform more complex functionality. There is a lot of freedom in this regard and, as long as the action types are sensibly named, it should always be fairly easy to see what is happening in the application (along with what is likely to be listening to a given action).

I've personally been leaning towards the 'sagas do everything' approach. It has the added benefit, given sensible file naming, of providing an at-a-glance overview of an application's functionality by simply opening the sagas directory. It also allows me to easily enhance functionality if desired, as the saga already exists.

/sagas
  index.js
  updateAThing.js
  loadAThing.js
  saveAThing.js
  createAnAlertEveryFifthClickForFun.js

Of course, your mileage may vary, so it is up to you to find a balance you're happy with!

Saga testing

I mentioned previously that one of the compelling features of redux-sagas was the promise of easy testing. The library itself performs admirably in this regard; all of the redux-saga effects, when yielded, are just objects which can be easily tested. Unfortunately, testing sagas is still a bit of a chore, to say the least.

The library attempts to mitigate a lot of issues with testing such side effect heavy functionality (not having to mock function calls is great!), but it doesn't change the unfortunate truth that writing tests for a generator, used in this fashion, is often painful. With generators, the order of yields can, and often do, matter. The ideal functional tests are ones that test input versus output, but don't strictly care about implementation; whenever the function changes, the tests provide a useful way of verifying that the output is still correct. However, testing a saga is nearly always about implementation (did the 'isLoading' property get set to true at the start, and then set to false before the saga ended?). And, true to form with any implementation based testing, such tests are very likely to break when making any change to the saga, even if the saga is still functioning correctly!

Test maintenance of sagas becomes arduous, and I'm currently on the fence as to whether it's worth the time investment to attain high levels of coverage. Sagas tend to be the 'brains' of the application that tie everything together, so having those tests is certainly justifiable; if we compare it to event-less stateless components, there is much more at stake (from a functionality point of view) if a saga is doing something wrong. It's also more likely a severe saga issue will slip by too, especially if it's a relatively niche bit of functionality that doesn't get called all that often. On the flip side, saga test writing can often boil down to simply being a check of how the saga was built, without it necessarily revealing whether or not it is correct. As a result, tests are simply not as reliable an indicator of validity than they are for, say, a mapping function.

I don't think this is a fatal blow for saga testing; it's just still early days and 'best practices' aren't readily available. Over time, this situation will hopefully improve and we'll see more reliable testing patterns for sagas. Also, as previously noted, sagas do usually contain extremely important functionality, so it may be a case of 'eating your vegetables' and writing the tests anyway, even if they are brittle.

The future

My initial impressions of sagas were pretty positive. Whilst I've not yet pushed into functionality far beyond anything I simply couldn't achieve sensibly with thunks, I've felt that they've cleaned up the codebase and made some existing patterns (such as request cancellation) much easier to work with. It was at the expense of some extra lines of code, but I've so far felt that the benefits I've been encountering have been worth it. Still, I've only begun to scratch the surface of using sagas. I see potential for them to prove extremely useful in complex workflows, due to the ability to easily yield and combine other sagas. Of course, the challenge will be to see if that potential really does translate into building robust complex applications, but I'm looking forward to experimenting!

Finally, of course, the question: 'should I be using sagas?' Well, I can definitely recommend giving them a go. They're a step up from thunks, and I think they do a great job of separating out the codebase into manageable layers. But, of course, it's always worth bearing in mind that the JS world is a hugely volatile one right now. Whilst sagas are pretty popular, there's no guarantee they will last long term. This the case for all libraries (even the big ones!), so I tend to judge a library by how pretty a fossil it will be when developers in 3-4 years look at the codebase and they've no idea what a 'saga' even is. In that respect I think the separation of the layers, and resulting simplicity, make sagas a fairly safe choice for adoption right now for building complex applications.

Author image
Kai is a front-end engineer at Onfido, and has definitely never even heard of Rick & Morty.
top