JS / How to manage complex async flows in your app

web development Oct 06, 2019

Or how i learned to deal with chaos

Written for Medium.com

Problem

Almost every app needs to have granular control over its asynchronous flows. When it comes to long-running functions, you may want to be able to cancel them and to precisely know whats going on. It looks simple to put some code around your function to handle that case once.

But what if you have many nested async functions? What if some of them are logically cancellable, while other ones— not? What if you need to provide to user correct feedback in UI on execution process, not only on result?

Whenever more questions appear, you become more focused on how to do it, not on what you are doing.


Solution patterns

Execution process

It is trivial to add some code to your function to do it. Let’s dive into example.

async function longRunningFunc() {
 setStatus('pending');
 ...
 setStatus('stopped');
 return result;
}

Ok, it was easy. Let’s think about exceptions.

async function longRunningFunc() {
 setStatus('pending');
 ...
 try {
  data = await anotherFunc();
 } catch (error) {
  setStatus('stopped');
  return error;
 }
 ...
 try {
  data = await anotherNonCriticalFunc();
 } catch (error) {
  ...
 }
 ...
 setStatus('stopped');
 return result;
}

Now your function is littered more with code that is not related directly to what you are doing inside the function. You may want to shorten previous example with try/catch/finally statement around whole code, but it does not help to deal with different behaviour needed inside catch blocks. You have to keep in mind all that logic and make changes carefully.

Is it possible? Of course, yes. Does it looks good? Well, i think — no.

Execution process of nested functions

In previous section we have a good example of how to do it once. But how many functions do you have in your app? Multiply all the trash code by that amount and try do not forget all the relations after you are back from your vacation. I will provide way to solve that problem below.

Cancellation

Let’s imagine situation. User types something in text input and you need to fetch suggestions. Ok, we’re already debounced our handler. But why do we need to continue already ran fetch if user has continued to type? We can do it by following code:

let makeCancel = false;
async function makeFetch(input) {
 makeCancel = false;
 setStatus('pending');
 ...
 try {
  data = await fetchSuggestions(input);
 } catch (error) {
  setStatus('stopped');
  throw error;
 }
 if (makeCancel) {
  return;
 }
 ...
 putInState(data);
 ...
 setStatus('stopped');
 return result;
}

Pattern is clear. But your function is not as before. What if you have many async actions to call step-by-step? Easy, you will put more and more if statements inside your function.

Same again. Is it possible? Yes. Does it looks good? I’m not sure.

Nested cancellation

At first glance it looks not so easy. And it is not easy. But fortunately JavaScript has great instruments to deal with it. Solution presented below uses generators and promises under the hood to provide you excellent experience and to solve all mentioned problems.


Getting all together

Let’s dive right in example how your code could look like. Consider the example on the same function as above:

function* makeFetch(input) {
 ...
 try {
  data = yield fetchSuggestions(input);
 } catch (error) {
  throw error;
 }
 ...
 putInState(data);
 ...
 yield result;
}

You can see the difference — clean and readable code. No more focus on how, but only on what.

So, where are status changes, cancellation, nesting, etc are handled? How to make everything work?

Meet Interruptible-Tasks! That tiny library aimed to solve all mentioned problems and to provide you awesome experience on building complex flows.

Are you building new project? Just dive into tutorial in README.MD and explore how to adopt your async functions to be used as generators. There are also some examples to start from.

Do you have some code already? No worries, there is no need to write anything from scratch. Minimal changes will be required. Please find more advanced examples by exploring tests of Interruptible-Tasks. Do not hesitate to come back to README.MD for API reference.


Examples

Let’s solve problems mentioned in the Problem section one by one. To focus on valuable things I will not describe whole API of the library, but it should be easy to understand. If not — please find detailed description in README.

Execution process

import { createTask, taskStatuses } from 'interruptible-tasks';
const taskName = 'connectedTask';
  const stateImitation = new Map();
const connect = (name, status) => {
    stateImitation.set(name, status);
  };
const task = createTask(
    function*() {
      yield new Promise(resolve => setTimeout(resolve, 10));
    },
    { interruptible: false, cancelable: false, name: taskName },
    connect
  );
let runPromise = task.run();
console.log(stateImitation.get(taskName) === taskStatuses.pending); // true
await runPromise;
console.log(stateImitation.get(taskName) === taskStatuses.stopped); // true

Here we can see connect function, which could wire updates from Interruptible-Tasks to any state used in your app (redux, mobx, vuex, vue-stash, hyperapp, your custom state like in example). It become trivial to notify user on inner running tasks.

But what about nested functions? Just convert them to Tasks and wire with same connect function. Done!

Cancellation

import { createTask } from 'interruptible-tasks';
const task = createTask(
    function*() {
      yield new Promise(resolve => setTimeout(resolve, 10));
      yield data;
    },
    { interruptible: false, cancelable: true, name: 'demoTask' }
  );
const runPromise = task.run();
console.log(task.cancel()); // true
await runPromise.catch(error => console.error); // TaskHasBeenCancelledError('Task demoTask has been cancelled')

Whenever you needed, just call .cancel() function and you’re done. Your Task’s Promise will be rejected as soon as next yield statement inside Task is reached. If passed, connect will be called automatically.

Nested cancellation works seamlessly if you have yielded nested Task in your function. Cancellation event will bubble down to all Tasks running. All Tasks statuses will be updated correctly and updates will be passed to corresponding connect functions you have provided.

Interruption

One new benefit you have now is Interruption pattern. Why to call .cancel() manually if you only need to start your task again?

import { createTask } from 'interruptible-tasks';
const task = createTask(
    function*(data) {
      yield new Promise(resolve => setTimeout(resolve, 10));
      yield data;
    },
    { interruptible: true, cancelable: false, name: 'demoTask' }
  );
task.run('not ok').catch(e => console.error); // after 10ms: TaskHasBeenInterruptedError('Task demoTask has been interrupted')
await task.run('ok'); //  after 10ms: 'ok'

If you made your Task interruptible (interruptible:true ) you have ability to run new Task and to have old one interrupted automatically.


Conclusion

Maybe you have a question like: Why not to use redux-saga or similar? My answer is: Why you have to? Maybe for particular project it is enough, or it is best solution indeed, but there also could be reasons not do so. You may not want to use such libraries for such small task; Maybe such libraries do not provide everything you need; Maybe you don’t want to be tied too much to any ecosystem like react+redux+redux-saga.

Anyway, you have to make a reasonable choice and i believe you can.

I’m really proud to present library that I’m using in my own projects to make code clean and modular. It helps to keep complex architecture solid and extendable.

If you have any questions or issues, or found an error, please feel free to reach me on GitHub project page or on Twitter.

Want a tasty app?

Tags