image credit: Eduardo Sánchez

Redux-Saga and AbortController for Truly Cancellable API Calls

Redux-Saga

Does your web app use Redux-Saga and the Fetch API to make a lot of repeated or lengthy calls to a particular server?

You've probably noticed that Chrome limits concurrent connections to any given host.

This can be a big problem if one part of your app ends up blocking another part due to pending API requests.

Obviously, you should try to reduce the number of calls you make, where possible. Consolidating requests and using debounce or throttle techniques can help sometimes.

But sometimes you don't control the server or requirements dictate that you need to make those API calls regardless. Sometimes you're at the mercy of a slow database, slow network connection, or an impatient user.

I'm going to show you how you can "fire and forget" all your API calls through Redux-Saga with true request cancellation built into the system via the AbortController API.

Redux-saga

Let's say you've got some redux-saga logic to make API calls for you. It might look something like this:

// refreshSaga.tsimport { put, takeLatest } from 'redux-saga';import { ActionTypes, refreshRequest, refreshSuccess, refreshFailure } from './actions';import { someApiCall } from './api';export function* refreshWatcher() {    // Wait for a REFRESH action    yield takeLatest(ActionTypes.REFRESH, refreshSaga);}export function* refreshSaga() {    let data: Data;    // Indicate the refresh call is starting    yield put(refreshRequest());    try {        // Make the API call        data = yield call(someApiCall);    }    catch (err) {        // Pass the error to our store        yield put(refreshFailed(err));        return;    }    // Pass the result to our store    yield put(refreshSuccess(data));}

The refreshWatcher waits for a REFRESH action to be dispatched to the store. Once it sees that, a new refreshSaga is forked.

Now what if your refreshes happen too frequently or for some reason the server starts responding slower than usual?

Redux-saga is going to start cancelling refreshSaga because we're using takeLatest. Only the most recent call gets to run to completion.

This can leave HTTP requests hanging open and even cause other calls to be blocked if too many connections are open. Albeit, this will be less noticeable if your server handles calls quickly. On the other hand, this problem is readily apparent with expensive calls such as complex database requests and long polling loops.

Determining when a saga has been cancelled

Redux-saga provides a mechanism to cancel sagas. You've already seen it in action with takeLatest, which handles cancellation for you.

Additionally, cancelled sagas have the ability to determine that they've been cancelled. Cancellation comes in the form of an Error that is thrown from an effect -- in this case, from our call effect.

Note that, technically, the saga could be cancelled from the put effect where we report the request starting, failing, or completing. In the first case, we don't care about cancellation because the API request hasn't actually started yet -- so there is nothing to cancel; and, in the other two cases, the API call has finished -- so, again, there is nothing to cancel.

export function* refreshSaga() {    let data: Data;    yield put(refreshRequest());    try {        data = yield call(someApiCall);    }    catch (err) {        if (yield(cancelled())) {            // the task was cancelled        }        yield put(refreshFailed(err));        return;    }    yield put(refreshSuccess(data));}

The saga cancellation notice in our exception handler is the ideal place to cancel our API call. Fortunately, the Fetch API now supports cancellation via AbortController.

Cancelling a Fetch request with AbortController

AbortController is an API that allows Fetch requests to be cancelled. It is available in Chrome 66, Firefox 57, Safari 11.1, Edge 16 (via caniuse.com).

You can add it to your saga like this.

export function* refreshSaga() {    const abortController = new AbortController();    let data: Data;    yield put(refreshRequest());    try {        //data = yield call(someApiCall);        data = yield call(someApiCall, abortController.signal);    }    catch (err) {        if (yield(cancelled())) {            // Cancel the API call if the saga was cancelled            abortController.abort();        }        yield put(refreshFailed(err));        return;    }    yield put(refreshSuccess(data));}

We create an instance of AbortController at the top of our saga. Notice we need to add a signal parameter to our API call. This signal needs to be placed in the request options object that is sent in the fetch request. I've done that for you in the example below.

// api.ts//export const someApiCall = async () =>export const someApiCall = async (signal: AbortSignal) =>    fetch('/some-end-point', {        method: 'POST',        signal,    });

Polyfilling for lesser browsers

Install abortcontroller-polyfill if you're concerned about supporting older browsers. This won't actually cancel the API calls. But it will prevent your app from crashing.

npm i --save abortcontroller-polyfill

A note about TypeScript support for AbortController

Older versions of TypeScript do not contain the AbortController and AbortSignal types in lib.dom.d.ts. I'm not exactly sure when they got added. But if you get errors about missing AbortController then check your version and upgrade to TypeScript v2.8.3 or newer.

npm i -D typescript@latest

Level up Your React + Redux + TypeScript

for free with articles, tutorials, sample code, and Q&A.