DEV Community

AvanishPai
AvanishPai

Posted on

Chaining async functions without using 'then'.

Recently, I came across a problem to create a jQuery-like API that allows changing of asynchronous functions in addition to doing some DOM manipulation. Through this article, I capture, share and try to take you through the thinking process that led me to the solution.

question

This bit of code must have brought some clarity to you about what we are trying to build here. If not I will try to explain the problem. We have a function '$' that takes in a CSS Selector. It picks the first element that matches the selector and then does a series of operations on it. Let us look at the operations to be performed one by one.

  1. addClass - it needs to add a class to the element selected by the CSS selector passed into the function.

  2. delay - it must delay the execution of the subsequent functions in the chain by a specified amount of time. This means that the subsequent operations of 'removeClass' and 'addClass' will happen only after a specified time seconds have passed.

  3. removeClass - it needs to remove a specified class from the element.

Now that you have a better idea of what we are trying to achieve. let's get our hands dirty with code!

FlawedBestAcornweevil-max-1mb

The $ function

For those familiar with jQuery the whole problem must have seemed quite familiar. But for those who haven't worked with jQuery here's an introduction to what $ does. $ is a function that takes in a CSS selector and selects an element from the DOM that matches the selector. Well, it doesn't really return the DOM element as such, but we will get to that later. For now, let's create a function that will return the DOM element.

function $(selector){
return document.querySelector(selector)
}
Enter fullscreen mode Exit fullscreen mode

That was fairly simple, wasn't it? we have the querySelector function that behaves exactly the same way we want the $ function to behave, so we just wrap it.

Next, we want to be able to chain a function 'addClass' to the $ function. For this to be possible we need to have the addClass method available on the object returned from the $ function.However, what we return now is the DOM element which does not have an addClass method. Let's look at how we can return an object that has an addClass method which will add a class to the selected DOM element.

The custom Element class

class Element {

  constructor(selector){
   this._element = document.querySelector(selector);
  }

  addClass(classname){
   this._element.classList.add(classname);
  }
//class end
}

function $(selector){
return new Element(selector);
}

$('#app').addClass('red');
Enter fullscreen mode Exit fullscreen mode

That's a lot of stuff going on in here! We replace the querySelector call in the $ function with the instantiation and returning of an object of new class we have written. We moved the query selector call to the constructor of the new class and set the element to a private variable _element. We wrote this new class so that we could chain an addClass function to the $ function. This is now possible as the object returned by $ has an addClass method!

Chaining synchronous methods

Let us make a slight change to the problem at this point. While the problem statement is that we should be able to chain the delay function next, let's skip that for now and look at chaining the removeClass function.

The 'addClass' method was the last operation in our chain, it did not return anything. This is an obstacle we need to get over to chain our removeClass method. What could we return from the addClass method to then chain the 'removeClass' method? which object would contain such a 'removeClass' method? could we accommodate 'removeClass' in the same 'Element' class?

addClass(classname){
this._element.classList.add(classname);
return this;
}

removeClass(classname){
this._element.classList.remove(classname);
return this;
}

//class end
}

function $(selector){
return new Element(selector);
}

$('#app').addClass('red').removeClass('red');
Enter fullscreen mode Exit fullscreen mode

with this code, let's answer those questions in the reverse order.

  1. We could use the same Element class to add the 'removeClass' method.
  2. When we have included the method on the Element class we can call the 'removeClass' method on the same object.
  3. If we can use the same object to call 'removeClass', we just need to return the object from 'addClass' method so that additional methods on the object can be chained. So, we do this by returning 'this'.

We have achieved chaining of the synchronous methods!

FastEnchantingKoi-small

Chaining the async functions

Ahh, now comes the difficult part. In the previous section, we decided to skip the delay function but real-life situations like interviews do not come with this skipping feature. So let's try to chain the delay function as well.

In the previous section, we learnt how to chain functions available on a class by returning the 'this'. This is the technique to follow for chaining any kind of function - which means that our delay function should also be a method on the class Element and must return the 'this'.

 delay(time){
 // do something that delays the execution of the next function by 
 // 'time' milliseconds.
   return this;
 }

//class end
}


function $(selector){
return new Element(selector);
}

$('#app').addClass('red').delay(3000).removeClass('red');

Enter fullscreen mode Exit fullscreen mode

We've achieved the chaining of the function delay as well, but the delay function isn't doing what it should - delay! how do we cause delays in JavaScript?

Yes, timeouts are the way to create delays in the JS. So we need to incorporate setTimeout in our delay function. The catch here is that we need to return the 'this' only after the delay because otherwise, the subsequent operation would occur before the delay is completed.

This is a good place for using Promises. Explaining what Promises are is beyond the scope of this article and also probably deserve one or two articles for itself (let me know in the comments if you'd like me to write an article on implementing your own Promise). If you're not familiar with Promises, async and await, the remainder of this article is probably going to go over your head. So I suggest you learn Promises and then come back and continue from here.

Whenever we think of chaining async functions, our minds quickly jump to the Promise chaining with the then functions. Implementing the promise here to chain the delay would look like this

delay(time){
return new Promise((resolve)=>{
setTimeout(()=>{},3000)
});
}

//class end
}

function $(selector){
return new Element(selector);
}

$('#app').addClass('red').delay(3000).removeClass('red');
Enter fullscreen mode Exit fullscreen mode

The problem here must be obvious, we are returning a Promise from the delay function. While Promises allow chaining methods using the 'then' method, the API we are trying to build doesn't have the 'then' method anywhere in the picture.

So, we're at a dead-end even with Promises. Let's take a step back, or in fact a few steps back and look at the API we're trying to build once again. We have a series of functions chained one after the other.Some(2) of these are synchronous while some(1) are asynchronous. Let's go through the chain - we first have 'addClass' which is synchronous, so we execute the function and return 'this'. Then we have a delay function which is asynchronous, we execute this function and have to wait for a period before we can execute the subsequent functions.

The key thing to note here is that even though the execution of the subsequent functions occurs only after the delay period, the chaining of the methods is immediate. This means that we must immediately return 'this' and defer the execution of the delay.

So while the execution of the delay is still happening, the subsequent operations are being chained, however, we must execute them only after the delay has been completed. So what can we do with them? We can add them to a queue of course!

Suppose we have a queue where we store the operations in the order they have been chained. When our delay is complete we can go through this queue on by one and execute our chained operations.

This is probably explained better through a real-life scenario. Imagine you're a bouncer at a club waiting at the entrance and letting people in one by one. people come and fall in a queue as they arrive. People generally carry their tickets in their hands and show it to you and you quickly let them in. However, a few people (inconsiderate of the others behind them) carry their tickets in their bags, and they start searching their bags only when they reach you. While these people keep searching standing at the front of the queue others keep falling into the queue behind them. Once the inconsiderate person has found his/her/their ticket you let them in and continue the process with the rest.

tenor

If the analogy isn't obvious - people are the methods, the people who don't carry their tickets in hands are the asynchronous ones and the bouncers are our execution. With this analogy in our minds let's rewrite some of our code.

class Elements {

constructor(selector){
this._element = document.querySelector(selector);
this._queue = []
this._paused = false;
}

 async executeTask(task) {
    return this[task.fn].apply(this, task.args);
  }

  async executeQueue() {
    if (this.paused) return;
    this.pause = true;
    while (this.queue.length) {
      const task = this.queue[0];
      this.paused = true;
      await this.executeTask(task);
      this.queue.shift();
    }
    this.pause = false;
  }

async _addClass(classname) {
    this._element.classList.add(classname);
    return this;
  }

  removeClass(...args) {
    this.queue.push({ fn: "_removeClass", args });
    this.executeQueue();
    return this;
  }

  async _removeClass(classname) {
    this._element.classList.remove(classname);
    return this;
  }

  delay(...args) {
    this.queue.push({ fn: "_delay", args });
    this.executeQueue();
    return this;
  }

  _delay(period) {
    return new Promise((resolve) => {
      setTimeout(() => resolve(), period);
    });
  }
}

function $(selector) {
  return new Element(selector);
}

$("#app")
  .addClass("red")
  .delay(3000)
  .removeClass("red")
Enter fullscreen mode Exit fullscreen mode

Yes, that isn't 'some' code that's been rewritten, that's the whole code being rewritten! But just stay with me and we'll quickly understand what's going on.

The constructor now initialises a queue and a variable called 'paused'. The queue is the one to which we will add our chained operations so that we can execute them one by one. Paused is the variable that tells us whether we have paused execution due to a pending delay operation (sort of like the bouncer waiting for the person to find their ticket).

addClass, removeClass and delay now do nothing but add an item to the queue indicating the corresponding operations to be performed and then attempt to execute the queue (try to get into the club) and finally return the 'this'.Since it returns 'this' immediately we can queue more operations immediately.

The actual operations are now the functions with _ prepended to their names. These operations have been marked async, we'll see why soon.

Before that let's look at our bouncer's function, aka executeQueue - whenever executeQueue is called we check if the execution is paused as one of the operations is still pending. If it's not we pick the operations(tasks) at the front of the queue and execute them, once they're executed, the next operation(task) is taken up. Since some of our operations are asynchronous in nature (delay) it is easier to assume that all of our operations may be asynchronous and use await on them. This is the reason why we have marked all our actual operations as async.

Before executing each task, we mark the paused variable as true and after the task is successfully executed we mark the paused variable as false. This is important as we do not want queued tasks to rush into execution before their turns. So, when the delay is being executed, no other method would be able to run the executeQueue logic.

Phew! That was a long article to write. I hope you've gotten an idea of how this works. You can find the full implementation of here: Full Solution

Do add your suggestions, feedbacks and criticisms below :)
And connect with me on LinkedIn if you'd like to!

Oldest comments (0)