Cancellation cannot be continued

Short description

The author of the Reatom state manager has discussed the main killer feature of redux-saga and rxjs, which is automatic cancellation of competing asynchronous chains. This is a mandatory property when working with any REST API and other more general asynchronous sequential operations. To avoid weird and unpredictable states, the author recommends adding cancellation through query versioning or using the native AbortController, which is well-supported in browsers and node.js. The author recommends using Reatom’s cancellation implementation, which uses the native AbortController and can be obtained directly from the context. This is an existing API and does not require specific knowledge or learning new concepts.

Cancellation cannot be continued

How to describe an asynchronous request chain and not break everything? Simply? Do not think!

I’m the author of the Reatom state manager and today I want to tell you about the main killer feature of redux-saga and rxjs and how it can now be obtained more easily, as well as the changes in the ECMAScript standard.

It will be about automatic cancellation of competing asynchronous chains – a mandatory property when working with any REST API and other more general asynchronous sequential operations.


▍ Basic example

const getA = async () => {
  const a = await api.getA();
  return a;
};

const getB = async (params) => {
  const b = await api.getB(params);
  return b;
};

const event = async () => {
  const a = await getA();
  const b = await getB(a);
  setState(b);
};

The example is as banal as possible, most of them wrote the following code: you need to request some data from the backend first, then request the final data from another endpoint based on it. The situation becomes more complicated if the first data depends on user input, most often it is some filters or sorting in the table. The user changes something, we make a request, the user changes something else, and we have already received a response from the previous request and until the new one is completed, the weird state is displayed.

But this is still nonsense, the vast majority of backend servers do not monitor the sequence of requests and can respond first to the second request, and then to the first – the user will be affected by the data for the old filters, and the new data will never appear – WAT state.

How to avoid WAT state from the example in the image? But it seems simple to cancel the last request.

It is not so difficult to put a cancellation on each specific request, although this code needs to be written, not everyone has ready-made tools at hand. The same axios out of the box does not provide such a feature, there is an opportunity to wake up the cancellation signal, but you need to control it yourself. No automation.

How could this be done? The easiest way to add cancellation is through query versioning.

let aVersion = 0;
const getA = async () => {
  const version = ++aVersion;
  const a = await api.getA();
  if (version !== aVersion) throw new Error("aborted");
  return a;
};

let bVersion = 0;
const getB = async (params) => {
  const version = ++bVersion;
  const b = await api.getB(params);
  if (version !== bVersion) throw new Error("aborted");
  return b;
};

const event = async () => {
  const a = await getA();
  const b = await getB(a);
  setState(b);
};

Boilerplate? But that’s not all. We fixed only the WAT state, but what about the weird state?

Our attempts to undo the previous request are getting nowhere, so we need to version the entire thread!

const getA = async (getVersion) => {
  const version = getVersion();
  const a = await api.getA();
  if (version !== getVersion()) throw new Error("aborted");
  return a;
};

const getB = async (getVersion, params) => {
  const version = getVersion();
  const b = await api.getB(params);
  if (version !== getVersion()) throw new Error("aborted");
  return b;
};

let version = 0
const getVersion = () => version
const event = async () => {
  version++
  const a = await getA(getVersion);
  const b = await getB(getVersion, a);
  setState(b);
};

Here we don’t use in every query

getVersion

from shorting, because in real code, we may have these functions scattered across different files, and we have to declare a common contract – passing the version function as the first argument.

But the task is solved! Canceling the chain prevents “weird state”

“WAT state” – also can no longer appear.

But the code looks even more boilerplate? We can now use the native AbortController, which is already well supported in browsers and node.js.

const getA = async (controller) => {
  const a = await api.getA();
  controller.throwIfAborted();
  return a;
};

const getB = async (controller, params) => {
  const b = await api.getB(params);
  controller.throwIfAborted();
  return b;
};

let prevController = new AbortController();
const event = async () => {
  prevController.abort("concurrent");
  controller = new AbortController();
  const a = await getA(controller);
  const b = await getB(controller, a);
  setState(b);
};

It’s gotten better and hopefully clearer, but it still feels awkward and wordy, you have to flip the controller around, is it worth it? In my practice, no one has done this, because no one will rewrite all the functions so that they normally interact with each other and the code is more consistent. Just as no one makes all functions async at all, you can read more about this in

How do you color your features?

. It is important to understand that the described example is as simplified as possible, and in real tasks the data flow and the corresponding problem can be much more complex and serious.

What are the alternatives? rxjs and redux-saga allow you to write code in your specific API, which under the hood automatically tracks competing calls to asynchronous threads and can cancel stale ones. The problem with this is precisely in the API – it is very specific, both in appearance and in behavior – the entrance threshold is quite large. Although less than $mol – yes, he knows how to auto-cancel, too.

IN @reduxjs/toolkit is createListenerMiddleware, whose API includes some features from redux-saga that allow solving primitive cases of this problem. But chain tracking is more local and not as well integrated into the toolkit API.

More options?

▍ Context

In this article, we only discuss automatic cancellation, but the general task is to look at the asynchronous context of the call. On the backend, asynchronous context has been around for a long time and is an important tool for reliable code. In node.js there is

AsyncLocalStorage

and now there is a discussion about its introduction into the standard (

Ecma TC39 proposal slides

)!

I can’t imagine how you can write complex (asynchronous and competitive, multi-step) business logic without an asynchronous context. More precisely, how to do it reliably.

Can it be used already, any polyphiles? Unfortunately, there is no. Tim Angular has been trying to do this with zone.js for a long time, but he never managed to cover all the cases.

But we can return to the question of awakening the first argument of some contextual meaning. This is exactly how it is done in Reatom – the first argument always comes ctx. This is a convention that is followed in all related functions and therefore it is very convenient, ctx contains several useful properties and methods, is immutable and helps with debugging, and can be overridden to make testing easier!

But let’s get back to our rams – automatic cancellation. There is a factory in the reatom/async package reatomAsync for wrapping asynchronous functions in the context tracker, which from the new version – automatically searches in the future ctx AbortController and subscribes to it. The controller itself can be canceled manually or using an operator withAbortwhich will cancel competing requests for you.

const getA = reatomAsync(async (ctx) => {
  const a = await api.getA();
  return a;
});

const getB = reatomAsync(async (ctx, params) => {
  const b = await api.getB(params);
  return b;
});

const event = reatomAsync(async (ctx) => {
  const a = await getA(ctx);
  const b = await getB(ctx, a);
  setState(b);
}).pipe(withAbort());

The beauty is that this is an existing API and adding support for AbortController was not that difficult. And it’s a very simple pattern – rolling over the first argument, it doesn’t require specific knowledge or learning new concepts – you should just adopt this convention and write a few more characters than possible. But if necessary, we can transparently expand the context by adding the necessary features to it. Importantly, the passed context is immutable and if in some rare case you run out

@reatom/logger

the context is easy to inspect and debate, there is a guide on this in the documentation.

To repeat, the important difference between Reatom’s cancellation implementation from rxjs and redux-saga is the use of native AbortController, which is already a standard, used in browsers and node.js, as well as many other libraries! Inside reatomAsync the controller itself can be obtained directly from the context (ctx.controler) and subscribe to the cancel or wake event signal native fetch. It is a good practice to cancel an existing browser request, because only ~6 connections can exist at a time. And in the case of other libraries that don’t provide AbortController, requests are canceled in the application, but frozen browsers can slow down new requests and getting fresh data.

It’s also cool that Reatom and its support packages are developed in the same monorip and integrate very well with each other. For example, onConnect from the @reatom/hooks package also wakes up the AbortController and cancels it when the transferred atom is unsubscribed – it works more simply and transparently than useEffect and the cleanup callback returned in React.

The article is also available in video format:

That’s all I wanted to say. Do you know of other libraries that allow you to do automatic cancellation? How about manually versioning and waking the AbortController, have you ever done this?

Telegram channel with prize draws, IT news and posts about retro games 🕹️

Related posts