pwshub.com

Mastering promise cancellation in JavaScript

Mastering Promise Cancellation In JavaScript

In JavaScript, Promises are a powerful tool for handling asynchronous operations, particularly useful in UI-related events. They represent a value that may not be available immediately but will be resolved at some point in the future.

Promises allow (or should allow) developers to write cleaner, more manageable code when dealing with tasks like API calls, user interactions, or animations. By using methods like .then(), .catch(), and .finally(), Promises enable a more intuitive way to handle success and error scenarios, avoiding the notorious “callback hell.”

In this article, we will use the new (March 2024) Promise.withResolvers() method that allows you to write cleaner and simpler code by returning an object containing three things: a new Promise and two functions, one to resolve the Promise and the other to reject it. As this is a recent update, you will need a recent Node runtime (v>22) to execute the examples in this article.

Comparing the old and new JavaScript promise methods

In the two following functionally equivalent chunks of code, we can compare the old approach and the new approach of assigning the method to either resolve or reject a Promise:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});
Math.random() > 0.5 ? resolve("ok") : reject("not ok");

In the code above, you can see the most traditional use of a Promise: you instantiate a new promise object, and then, in the constructor, you have to assign the two functions, resolve and reject, that will be invoked when needed.

In the following code snippet, the same chunk of code has been rewritten with the new Promise.withResolvers() method, and it appears simpler:

const { promise, resolve, reject } = Promise.withResolvers();
Math.random() > 0.5 ? resolve("ok") : reject("not ok");

Here you can see how the new approach works. It returns the Promise, on which you can invoke the .then() method and the two functions, resolve and reject.

The traditional approach to Promises encapsulates the creation and event-handling logic within a single function, which can be limiting if multiple conditions or different parts of the code need to resolve or reject the promise.

In contrast, Promise.withResolvers() provides greater flexibility by separating the creation of the Promise from the resolution logic, making it suitable for managing complex conditions or multiple events. However, for straightforward use cases, the traditional method may be simpler and more familiar to those accustomed to standard promise patterns.

Real-world example: Calling an API

We can now test the new approach on a more realistic example. In the code below, you can see a simple example of an API invocation:

function fetchData(url) {
    return new Promise((resolve, reject) => {
        fetch(url)
            .then(response => {
                // Check if the response is okay (status 200-299)
                if (response.ok) {
                    return response.json(); // Parse JSON if response is okay
                } else {
                    // Reject the promise if the response is not okay
                    reject(new Error('API Invocation failed'));
                }
            })
            .then(data => {
                // Resolve the promise with the data
                resolve(data);
            })
            .catch(error => {
                // Catch and reject the promise if there is a network error
                reject(error);
            });
    });
}
// Example usage
const apiURL = '<ADD HERE YOU API ENDPOINT>';
fetchData(apiURL)
    .then(data => {
        // Handle the resolved data
        console.log('Data received:', data);
    })
    .catch(error => {
        // Handle any errors that occurred
        console.error('Error occurred:', error);
    });

The fetchData function is designed to take a URL and return a Promise that handles an API call using the fetch API. It processes the response by checking if the response status is within the 200-299 range, indicating success.

If successful, the response is parsed as JSON, and the Promise is resolved with the resulting data. If the response is not successful, the Promise is rejected with an appropriate error message. Additionally, the function includes error handling to catch any network errors, rejecting the Promise if such an error occurs.

The example demonstrates how to use this function, showing how to manage the resolved data with a .then() block and handle errors using a .catch() block, ensuring that both successful data retrieval and errors are managed appropriately.

In the code below, we re-write the fetchData() function by using the new Promise.withResolvers() method:

function fetchData(url) {
    const { promise, resolve, reject } = Promise.withResolvers();
    fetch(url)
        .then(response => {
            // Check if the response is okay (status 200-299)
            if (response.ok) {
                return response.json(); // Parse JSON if response is okay
            } else {
                // Reject the promise if the response is not okay
                reject(new Error('API Invocation failed'));
            }
        })
        .then(data => {
            // Resolve the promise with the data
            resolve(data);
        })
        .catch(error => {
            // Catch and reject the promise if there is a network error
            reject(error);
        });
    return promise;
}

As you can see, the code above is more readable, and the role of the object Promise is clear: the fetchData function will return a Promise that will be successfully resolved or will fail, invoking – in each case – the proper method. You can find the code above on the repository named api.invocation.{old|new}.js.

Promises cancellation

The following code explores how to implement a Promise cancellation method. As you may know, you cannot cancel a Promise in JavaScript. Promises represent the result of an asynchronous operation and they are designed to resolve or reject once created, with no built-in mechanism to cancel them.

This limitation arises because Promises have a defined state transition process; they start as pending and, once settled, cannot change state. They are meant to encapsulate the result of an operation rather than control the operation itself, which means they cannot influence or cancel the underlying process. This design choice keeps Promises simple and focused on representing the eventual outcome of an operation:

const cancellablePromise = () => {
    const { promise, resolve, reject } = Promise.withResolvers();
    promise.cancel = () => {
        reject("the promise got cancelled");
    };
    return promise;
};

In the code above, you can see the object named cancellablePromise, which is a promise with an additional cancel() method that, as you can see, simply forces the invocation of the reject method. This is just syntactic sugar and does not cancel a JavaScript Promise, though it may help in writing clearer code.

An alternative approach is to use an AbortController and AbortSignal, which can be tied to the underlying operation (e.g., an HTTP request) to cancel it when needed. From the documentation, you can see that the AbortController and AbortSignal approach is a more expressive implementation of what we implemented in the code above: once the AbortSignal is invoked, the promise just gets rejected.

Another approach is to use reactive programming libraries like RxJS, which offers an implementation of the Observable pattern, a more sophisticated control over async data streams, including cancellation capabilities.

A comparison between Observables and Promises

When speaking about practical use cases, Promises are well-suited for handling single asynchronous operations, such as fetching data from an API. In contrast, Observables are ideal for managing streams of data, such as user input, WebSocket events, or HTTP responses, where multiple values may be emitted over time.

We already clarified that once initiated, Promises cannot be canceled, whereas Observables allow for cancellation by unsubscribing from the stream. The general idea is that, with Observables, you have an explicit structure of the possible interaction with the object:

  • You create an Observable, and then all the Observables can subscribe to it
  • The Observable carries out its work, changing state and emitting events. All the Observers will receive the updates – this is the main difference with Promises. A Promise can be resolved just once while the Observables can keep emitting events as long as there are Observers
  • Once the Observer is not interested in the events from the Observables, it can unsubscribe, freeing resources

This is demonstrated in the code below:

import { Observable } from 'rxjs';
const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);
  subscriber.complete();
});
const observer = observable.subscribe({
  next(x) { console.log('Received value:', x); },
  complete() { console.log('Observable completed'); }
});
observer.unsubscribe();

This code cannot be rewritten with Promises because the Observable returns three values while a Promise can only be resolved once.

To experiment further with the unsubscribe method, we can add another Observer that will use the takeWhile() method: it will let the Observer wait for values to match a specific condition; in the code below, for example, it keeps receiving events from the Observable while the value is not 2:

import { Observable, takeWhile } from 'rxjs';
const observable = new Observable(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);
  subscriber.complete();
});
const observer1 = observable.subscribe({
  next(x) { console.log('Received by 1 value:', x); },
  complete() { console.log('Observable 1 completed'); }
});
const observer2 = observable.pipe(
  takeWhile(value => value != "2")
).subscribe(value => console.log('Received by 2 value:', value));

In the code above, observer1 is the same as we have already seen: it will just subscribe and keep receiving all the events from the Observable. The second one, observer2, will receive elements from the Observable while the condition is matched. In this case, this means when the value is different from 2.

From the execution, you can see how the two different mechanisms work:

$ node observable.mjs
Received by 1 value: 1
Received by 1 value: 2
Received by 1 value: 3
Observable 1 completed
Received by 2 value: 1
$

Conclusion

In this article, we investigated the new mechanism to allocate a Promise in JavaScript and laid out some of the possible ways to cancel a Promise before its completion. We also compared Promises with Observable objects, which not only offer the features of Promises but extend them by allowing multiple emissions of events and a proper mechanism for unsubscribing.

Hey there, want to help make our blog better?

Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.

Sign up now

Source: blog.logrocket.com

Related stories
6 days ago - You may have seen the shiny technologies like React, Vue, and Angular that promise to revolutionize your front-end development. It's tempting to dive headfirst into these frameworks, eager to build stunning user interfaces. But hold on!...
1 week ago - Explore animations in React Native apps and how LottieFiles simplifies the process of embedding custom animations into your app. The post Mastering Lottie animations for React Native apps appeared first on LogRocket Blog.
3 weeks ago - Finding the right typeface for a logo is a challenge and can be a very time-consuming process that requires both creativity and a practical approach. Levi Honing provides the essential background and tools to enhance your typography...
1 month ago - Mastering system design is important for anyone who wants to build scalable and reliable applications. System design includes a range of topics from basic computer architecture to complex networking concepts, each playing an important...
1 month ago - In this course, you'll learn how to work adeptly with the pandas GroupBy while mastering ways to manipulate, transform, and summarize data. You'll work with real-world datasets and chain GroupBy methods together to get data into an output...
Other stories
1 hour ago - Hello, everyone! It’s been an interesting week full of AWS news as usual, but also full of vibrant faces filling up the rooms in a variety of events happening this month. Let’s start by covering some of the releases that have caught my...
1 hour ago - Nitro.js is a solution in the server-side JavaScript landscape that offers features like universal deployment, auto-imports, and file-based routing. The post Nitro.js: Revolutionizing server-side JavaScript appeared first on LogRocket Blog.
1 hour ago - Information architecture isn’t just organizing content. It's about reducing clicks, creating intuitive pathways, and never making your users search for what they need. The post Information architecture: A guide for UX designers appeared...
1 hour ago - Enablement refers to the process of providing others with the means to do something that they otherwise weren’t able to do. The post The importance of enablement for business success appeared first on LogRocket Blog.
3 hours ago - Learn how to detect when a Bluetooth RFCOMM serial port is available with Web Serial.