Elegant asynchronous programming with promises

Good day, friends!

Promises (promises) are a relatively new feature of JavaScript that allows you to delay the execution of an action until the completion of the previous action or to respond to an unsuccessful action. This contributes to the correct determination of the sequence of asynchronous operations. This article discusses how promises work, how they are used in the Web API, and how you can write your own promise.

Conditions: basic computer literacy, knowledge of the basics of JS.
Objective: understand what promises are and how they are used.

What are promises?


We briefly reviewed the promises in the first article of the course, here we will consider them in more detail.

In essence, a promise is an object that represents an intermediate state of an operation - it “promises” that the result will be returned in the future. It is not known exactly when the operation will be completed and when the result will be returned, but there is a guarantee that when the operation is completed, your code will either do something with the result or gracefully handle the error.

As a rule, how much time an asynchronous operation takes (not too long!) Is of less interest to us than the possibility of an immediate response to its completion. And, of course, it's nice to know that the rest of the code is not blocked.

One of the most common promises are Web APIs that return promises. Let's look at a hypothetical video chat application. The application has a window with a list of friends of the user, clicking on the button next to the name (or avatar) of the user starts a video call.

The button handler calls getUserMedia ()to access the user's camera and microphone. From the moment getUserMedia () contacts the user for permission (to use devices, which device to use if the user has several microphones or cameras, only a voice call, among other things), getUserMedia () expects not only the user's decision, but also release devices if they are currently in use. In addition, the user may not respond immediately. All this can lead to large delays in time.

If a request for permission to use devices is made from the main thread, the browser is blocked until getUserMedia () is complete. It is unacceptable. Without promises, everything in the browser becomes “non-clickable” until the user gives permission to use the microphone and camera. Therefore, instead of waiting for the user's decision and returning a MediaStream for a stream created from sources (camera and microphone), getUserMedia () returns a promise that MediaStream processes as soon as it becomes available.

The video chat application code might look like this:

function handle CallButton(evt){
    setStatusMessage('Calling...')
    navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then(chatStream => {
        selfViewElem.srcObject = chatStream
        chatStream.getTracks().forEach(track => myPeerConnection.addTrack(track, chatStream))
        setStatusMessage('Connected')
    }).catch(err => {
        setStatusMessage('Failed to connect')
    })
}

The function begins by calling setStatusMessage (), displaying the message 'Calling ...', which serves as an indicator that an attempt is being made to make a call. Then getUserMedia () is called, requesting a stream that contains video and audio tracks. Once the stream is formed, a video element is installed to display the stream from the camera called 'self view', audio tracks are added to WebRTC RTCPeerConnection , which is a connection to another user. After that, the status is updated to 'Connected'.

If getUserMedia () fails, the catch block is launched. It uses setStatusMessage () to display an error message.

Note that the call to getUserMedia () is returned even if the video stream has not yet been received. Even if the handleCallButton () function returned control to the code that called it, as soon as getUserMedia () completed execution, it would call the handler. Until the application “understands” that the broadcast has begun, getUserMedia () will be in standby mode.

Note: you can learn more about this in the article “Signals and video calls” . This article provides more complete code than the one we used in the example.

Callback Functions Problem


To understand why promises are a good solution, it’s useful to look at the old way of writing callbacks to see what their problems were.

As an example, consider ordering pizza. A successful pizza order consists of several steps that must be performed in order, one after the other:

  1. Choose the filling. This can take some time if you think about it for a long time, and fail if you change your mind and order a curry.
  2. We place an order. Cooking pizza takes some time and may fail if the restaurant lacks the necessary ingredients.
  3. We get pizza and eat. Receiving pizza may fail if, for example, we cannot pay for the order.

An old-style pseudocode using callback functions might look like this:

chooseToppings(function(toppings){
    placeOrder(toppings, function(order){
        collectOrder(order, function(pizza){
            eatPizza(pizza)
        }, failureCallback)
    }, failureCallback)
}, failureCallback)

This code is hard to read and maintain (it is often called the “hell of callbacks” or the “hell of callbacks”). The failureCallback () function must be called at each nesting level. There are other problems.

We use promises


Promises make the code cleaner, easier to understand and support. If we rewrite the pseudocode using asynchronous promises, we get the following:

chooseToppings()
.then(function(toppings){
    return placeOrder(toppings)
})
.then(function(order){
    return collectOrder(order)
})
.then(function(pizza){
    eatPizza(pizza)
})
.catch(failureCallback)

This is much better - we see what happens, we use one .catch () block to handle all errors, the function does not block the main stream (so we can play video games while waiting for pizza), each operation is guaranteed to be performed after the previous one is completed. Since every promise returns a promise, we can use the .then chain. Great, right?

Using arrow functions, pseudo-code can be further simplified:

chooseToppings()
.then(toppings =>
    placeOrder(toppings)
)
.then(order =>
    collectOrder(order)
)
.then(pizza =>
    eatPizza(pizza)     
)
.catch(failureCallback)

Or even like this:

chooseToppings()
.then(toppings => placeOrder(toppings))
.then(order => collectOrder(order))
.then(pizza => eatPizza(pizza))
.catch(failureCallback)

This works because () => x is identical to () => {return x}.

You can even do this (since functions simply pass parameters, we don’t need layering):

chooseToppings().then(placeOrder).then(collectOrder).then(eatPizza).catch(failureCallback)

Such code is more difficult to read and cannot be used with more complex constructs than in pseudocode.

Note: pseudo-code can still be improved using async / await, which will be discussed in a future article.

At its core, promises are similar to event listeners, but with some differences:

  • A promise is fulfilled only once (success or failure). It cannot be executed twice or switch from success to failure or vice versa after the operation is completed.
  • If the promise is completed, and we add a callback function to process its result, then this function will be called, despite the fact that the event occurred before the function was added.

Basic promise syntax: a real example


Knowing promises is important because many Web APIs use them in functions that perform potentially complex tasks. Working with modern web technologies involves the use of promises. Later we will learn how to write our own promises, but for now, let's look at a few simple examples that can be found in the Web API.

In the first example, we use the fetch () method to get the image from the network, the blob () method to convert the contents of the response body to a Blob object and display this object inside the <img> element . This example is very similar to the example from the first article , but we will do it a little differently.

Note: the following example will not work if you simply run it from a file (i.e. using file: // URL). You need to run it through a local server or using online solutions such as Glitch or GitHub pages .

1. First of all, upload the HTML and image that we will receive.

2. Add the <script> element to the end of the <body> .

3. Inside the <script> element, add the following line:

let promise = fetch('coffee.jpg')

The fetch () method, which takes the image URL as a parameter, is used to get the image from the network. As the second parameter, we can specify an object with settings, but for now we will restrict ourselves to a simple option. We store the promise returned by fetch () in the promise variable. As noted earlier, a promise is an object that represents an intermediate state — the official name for a given state is pending.

4. To work with the result of the successful completion of the promise (in our case, when Response returns ), we call the .then () method. The callback function inside the .then () block (often referred to as the executor) is launched only when the promise completes successfully and the Response object is returned - they say that the promise has completed (fulfilled, fullfilled). Response is passed as a parameter.

Note. The operation of the .then () block is similar to the operation of the AddEventListener () event listener. It is triggered only after the event occurs (after fulfilling the promise). The difference between the two is that .then () can only be called once, while the listener is designed for multiple calls.

After receiving the response, we call the blob () method to convert the response into a Blob object. It looks like this:

response => response.blob()

... which is a short entry for:

function (response){
    return response.blob()
}

OK, enough words. Add the following after the first line:

let promise2 = promise.then(response => response.blob())

5. Each call to .then () creates a new promise. This is very useful: since blob () also returns a promise, we can process the Blob object by calling .then () on the second promise. Given that we want to do something more complicated than calling the method and returning the result, we need to wrap the body of the function in curly braces (otherwise, an exception will be thrown):

Add the following to the end of the code:

let promise3 = promise2.then(myBlob => {

})

6. Let's fill in the body of the executing function. Add the following lines to the braces:

let objectURL = URL.createObjectURL(myBlob)
let image = document.createElement('img')
image.src = objectURL
document.body.appendChild(image)

Here we call the URL.createObjectURL () method, passing it as a Blob parameter, which returned the second promise. We get a link to the object. Then we create an <img> element, set the src attribute with the value of the object link and add it to the DOM so that the image appears on the page.

If you save the HTML and load it in a browser, you will see that the image is displayed as expected. Great job!

Note. You have probably noticed that these examples are somewhat contrived. You could do without the fetch () and blob () methods and assign the <img> corresponding URL, coffee.jpg. We went this way to demonstrate working with promises with a simple example.

Failure Response


We forgot something - we don’t have an error handler in case one of the promises fails (will be rejected). We can add an error handler using the .catch () method. Let's do that:

let errorCase = promise3.catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message)
})

In order to see this in action, specify the wrong image URL and reload the page. An error message will appear in the console.

In this case, if we do not add the .catch () block, an error message will also be displayed in the console. However .catch () allows us to handle errors the way we want it. In a real application, your .catch () block may try to retake the image or display the default image, or ask the user to select another image or do something else.

Note. Watch a live demo ( source code ).

Block merging


In fact, the approach we used to write the code is not optimal. We deliberately went this way so that you can understand what is happening at each stage. As previously shown, we can combine .then () blocks (and .catch () blocks). Our code can be rewritten as follows (also see simple-fetch-chained.html on GitHub):

fetch('coffee.jpg')
.then(response => response.blob())
.then(myBlob => {
    let objectURL = URL.createObjectURL(myBlob)
    let image = document.createElement('img')
    image.src = objectURL
    document.body.appendChild(image)
})
.catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message)
})

Remember that the value returned by the promise is passed as a parameter to the next function executor of the .then () block.

Note. The .then () /. Catch () blocks in promises are asynchronous equivalents of the synchronous try ... catch block. Remember: synchronous try ... catch will not work in asynchronous code.

Promise Terminology Conclusions


Let’s take stock and write a short guide that you can use in the future. To consolidate knowledge, we recommend that you read the previous section several times.

1. When the promise is created, they say that it is in a state of expectation.
2. When the promise is returned, they say that it was completed (resolved):

  1. 1. A successfully completed promise is called fulfilled. It returns the value that can be obtained through the chain from .then () at the end of the promise. The executing function in the .then () block contains the value returned by the promise.
  2. 2. An unsuccessfully completed promise is called rejected. It returns the reason, the error message that led to the rejection of the promise. This reason can be obtained through the .catch () block at the end of the promise.

Run the code after several promises


We learned the basics of using promises. Now let's look at more advanced features. The chain from .then () is fine, but what if we want to call several blocks of promises one by one?

You can do this using the standard Promise.all () method. This method takes an array of promises as a parameter and returns a new promise when all the promises in the array are fulfilled. It looks something like this:

Promise.all([a,b,c]).then(values => {
        ...
})

If all the promises are fulfilled, the array executor of the .then () block will be passed as a parameter. If at least one of the promises is not fulfilled, the entire block will be rejected.

This can be very helpful. Imagine that we receive information for dynamically filling the user interface with content on our page. In many cases, it is more reasonable to receive all the information and display it on the page later than displaying the information in parts.

Let's look at another example:
1. Download a page template and place the <script> tag before the closing </body> tag.

2. Download the source files ( coffee.jpg , tea.jpg and description.txt) or replace them with yours.

3. In the script, we first define a function that returns the promises that we pass to Promise.all (). This will be easy to do if we run Promise.all () after completing the three fetch () operations. We can do the following:

let a = fetch(url1)
let b = fetch(url2)
let c = fetch(url3)

Promise.all([a, b, c]).then(values => {
    ...
})

When the promise is fulfilled, the “values” variable will contain three Response objects, one from each completed fetch () operation.

However, we do not want this. It doesn't matter to us when the fetch () operations complete. What we really want is the loaded data. This means that we want to run the Promise.all () block after receiving valid blob representing images and valid text strings. We can write a function that does this; add the following to your <script> element:

function fetchAndDecode(url, type){
    return fetch(url).then(response => {
        if(type === 'blob'){
            return response.blob()
        } else if(type === 'text'){
            return response.text()
        }
    }).catch(e => {
        console.log('There has been a problem with your fetch operation ' + e.message)
    })
}

It looks a bit complicated, so let's go through the code step by step:

1. First of all, we declare a function and pass it the URL and the type of the resulting file.

2. The structure of the function is similar to that we saw in the first example - we call the fetch () function to get the file at a specific URL and then transfer the file to another promise that returns the decoded (read) response body. In the previous example, it was always the blob () method.

3. There are two differences:

  • First, the second return promise depends on the type of value. Inside the executing function, we use the if ... else if operator to return the promise depending on the type of file we need to decode (in this case, we choose between blob and text, but the example can be easily extended to work with other types).
  • -, «return» fetch(). , (.. , blob() text(), , , ). , return .

4. At the end, we call the .catch () method to handle any errors that may occur in promises in the array passed to .all (). If one of the promises is rejected, the catch block will let us know what promise the problem was with. The .all () block will still execute, but it won’t display the results for which there were errors. If you want .all () to be rejected, add a .catch () block at the end.

The code inside the function is asynchronous and based on promises, so the whole function works like a promise.

4. Next, we call our function three times to start the process of receiving and decoding images and text, and put each of the promises in a variable. Add the following:

let coffee = fetchAndDecode('coffee.jpg', 'blob')
let tea = fetchAndDecode('tea.jpg', 'blob')
let description = fetchAndDecode('description.txt', 'text')

5. Next, we declare a Promise.all () block to execute some code only after all three promises have been fulfilled. Add a block with an empty executor function inside .then ():

Promise.all([coffee, tea, description]).then(values => {

})

You can see that it takes an array of promises as a parameter. The contractor will start only after all three promises are fulfilled; when this happens, the result of each promise (the decoded response body) will be placed in an array, this can be represented as [coffee-results, tea-results, description-results].

6. Finally, add the following to the executor (here we use a fairly simple synchronous code to put the results in variables (creating URL objects from blob) and display images and text on the page):

console.log(values)
//       
let objectURL1 = URL.createObjectURL(values[0])
let objectURL2 = URL.createObjectURL(values[1])
let descText = values[2]

//  
let image1 = document.createElement('img')
let image2 = document.createElement('img')
image1.src = objectURL1
image2.src = objectURL2
document.body.appendChild(image1)
document.body.appendChild(image2)

//  
let para = document.createElement('p')
para.textContent = descText
document.body.appendChild(para)

Save the changes and reload the page. You should see that all user interface components are loading, although this does not look very attractive.

Note. If you have any difficulties, you can compare your version of the code with ours - here is a live demo and source code .

Note. In order to improve this code, you can loop through the displayed elements, receiving and decoding each, and then loop through the results inside Promise.all (), launching different functions to display each result depending on its type. This will allow you to work with any number of elements.

In addition, you can determine the type of the resulting file without having to explicitly specify the type property. This, for example, can be done withresponse.headers.get ('content-type') for checking the HTTP Protocol Content-Type header .

Run the code after fulfilling / rejecting the promise


Often, you may need to execute code after a promise has been completed, regardless of whether it was executed or rejected. Previously, we had to include the same code in both the .then () block and the .catch () block, for example:

myPromise
.then(response => {
    doSomething(response)
    runFinalCode()
})
.catch(e => {
    returnError(e)
    runFinalCode()
})

In modern browsers, the .finally () method is available, which allows you to run the code after the promise is completed, which avoids repetition and makes the code more elegant. The code from the previous example can be rewritten as follows:

myPromise
.then(response => {
    doSomething(response)
})
.catch(e => {
    returnError(e)
})
.finally(() => {
    runFinalCode()
})

You can see the use of this approach with a real example - the live demo promise-finally.html ( source code ). It works the same as Promise.all () from the previous example, except that we add finally () to the end of the chain in the fetchAndDecode () function:

function fetchAndDecode(url, type){
    return fetch(url).then(response => {
        if(type === 'blob'){
            return response.blob()
        } else if(type === 'text'){
            return response.text()
        }
    }).catch(e => {
        console.log(`There has been a problem with your fetch operation for resource "${url}": ${e.message}`)
    }).finally(() => {
        console.log(`fetch attempt for "${url}" finished.`)
    })
}

We will receive a message about the completion of each attempt to get the file.

Note. then () / catch () / finally () is the asynchronous equivalent of synchronous try () / catch () / finally ().

Writing Your Own Promise


The good news is that you already know how to do it. When you combine multiple promises with .then (), or combine them to provide specific functionality, you create your own asynchronous and promise-based functions. Take for example our fetchAndDecode () function from the previous example.

Combining various promise-based APIs to create specific functionality is the most common way to work with promises, which shows the flexibility and power of modern APIs. However, there is another way.

Constructor Promise ()


You can create your own promise using the Promise () constructor. This may be necessary in a situation where you have code from the old asynchronous API that you need to "oversize." This is done in order to be able to simultaneously work both with existing, old code, libraries or “frameworks”, and with new promise-based code.

Let's look at a simple example - here we wrap the setTimeout () call in a promise - this will start the function in 2 seconds, which will fulfill the promise (using the resolve () call passed) with the string “Success!”.

let timeoutPromise = new Promise((resolve, reject) => {
    setTimeout(function(){
        resolve('Success!')
    }, 2000)
})

resolve () and reject () are functions that are called to fulfill or reject a new promise. In this case, the promise is fulfilled with the string “Success!”.

When you call this promise, you can add a .then () block to it to further work with the “Success!” Line. So we can output a line in the message:

timeoutPromise
.then((message) => {
    alert(message)
})

... or so:

timeoutPromise.then(alert)

Watch a live demo ( source code ).

The given example is not very flexible - the promise can only be fulfilled with a simple line, we do not have an error handler - reject () (actually setTimeout () does not need an error handler, so in this case it does not matter).

Note. Why resolve () rather than fulfill ()? At the moment, the answer is this: it's hard to explain.

We work with a rejection of a promise


We can create a rejected promise using the reject () method - just like resolve (), reject () takes a simple value, but unlike resolve (), the simple value is not the result, but the reason for the rejection, i.e. an error that is passed to the .catch () block.

Let's expand the previous example by adding a condition for rejecting a promise, as well as the ability to receive messages other than a success message.

Take the previous example and rewrite it like this:

function timeoutPromise(message, interval){
    return new Promise((resolve, reject) => {
        if(message === '' || typeof message !== 'string'){
            reject('Message is empty or not a string')
        } else if(interval < 0 || typeof interval !== number){
            reject('Interval is negative or not a number')
        } else{
            setTimeout(function(){
                resolve(message)
            }, interval)
        }
    })
}

Here we pass two arguments to the function - message and interval (time delay). In a function, we return a Promise object.

In the Promise constructor, we perform several checks using if ... else structures:

  1. First, we check the message. If it is empty or is not a string, then we reject the promise and report an error.
  2. Secondly, we check the time delay. If it is negative or not a number, then we also reject the promise and report an error.
  3. Finally, if both arguments are OK, display the message after a certain time (interval) using setTimeout ().

Since timeoutPromise () returns a promise, we can add .then (), .catch (), etc. to it to improve its functionality. Let's do that:

timeoutPromise('Hello there!', 1000)
.then(message => {
    alert(message)
}).catch(e => {
    console.log('Error: ' + e)
})

After saving the changes and running the code, you will see a message after one second. Now try passing an empty string as a message or a negative number as an interval. You will see an error message as a result of rejecting the promise.

Note. You can find our version of this example on GitHub - custom-promise2.html ( source ).

More realistic example.


The example we examined makes it easy to understand the concept, but it is not truly asynchronous. Asynchrony in the example is simulated using setTimeout (), although it still shows the usefulness of promises for creating functions with a reasonable workflow, good error handling, etc.

One example of a useful asynchronous application that uses the Promise () constructor is idb library (IndexedDB with usability)Jake Archibald. This library allows you to use the IndexedDB API (based on the API callback functions for storing and retrieving data on the client side), written in the old style, with promises. If you look at the main library file, you will see that it uses the same techniques that we examined above. The following code converts the basic query model used by many IndexedDB methods to a promise compatible model:

function promisifyRequest(request){
    return new Promise(function(resolve, reject){
        request.onsuccess = function(){
            resolve(request.result)
        }

        request.onerror = function(){
            reject(request.error)
        }
    })
}

This adds a couple of event handlers that fulfill or reject the promise, as appropriate:

  • When the request succeeds , the onsuccess handler fulfills the promise with the result of the request.
  • When the request fails , the onerror handler rejects the promise with the error of the request.

Conclusion


Promises are a great way to create asynchronous applications when we don’t know what value the function will return or how long it will take. They allow you to work with a sequence of asynchronous operations without using deeply nested callback functions, and they support the error handling style of the synchronous try ... catch statement.

Promises are supported by all modern browsers. The one exception is Opera Mini and IE11 and its earlier versions.

In this article, we have considered far from all the features of promises, only the most interesting and useful ones. You'll get to know other features and techniques if you want to learn more about promises.

Most modern Web APIs are based on promises, so promises need to be known. Among these Web APIs we can name WebRTC , Web Audio API , Media Capture and Streams , etc. Promises will become more and more popular, so their study and understanding is an important step towards mastering modern JavaScript.

Also see “Common Mistakes in JavaScript Promises Everyone Should Know About . ”

Thank you for attention.

Constructive criticism is welcome.

All Articles