February 03, 2020

JavaScript Promises, but why?

Author: Kamil Biel · 10 minute read

BACK TO POSTS

This is just a quick write up on why we use Promises. Today we will not touch async/await control flow, instead we will focus on some of the most useful methods Promises implement - then, catch and all.

Going asynchronous

Let's suppose we want to fetch some data from an API and log it to our console when the call is done. With synchronous code it could look like that:

const getAndLog = endpoint => {
// synchronous api call
const response = api.fetch(endpoint);
// logs reponse
console.log(response);
}

But what happens if we want api.fetch(...) to be asynchronous? No data will be logged to the console, console.log is being fired before api.fetch finishes its job.

Callbacks

The obvious way of dealing with this problem is using callbacks. Our api could support callbacks that are being fired after the response is available.

const getAndLog = endpoint => {
// asynchronous api.fetch(endpoint, [,callback(response)]);
const response = api.fetch(endpoint, console.log);
}

But what if our callback is not as trivial as console.log and also needs to asynchronously fetch data to continue? We will quickly realize that callback hell is a real thing.

The Case

Let's suppose we want to make two api calls and combine the results in one way or another. With callbacks it could look like this:

const getOne = callback => api.fetch('/one', callback);
const getTwo = callback => api.fetch('/two', callback);
const renderCombined = renderer => {
getOne(responseOne => {
getTwo(responseTwo => {
// code combining two responses
// const combined = ...
// ...
renderer(combined);
})
})
}

Doesn't look so bad huh. But what about errors? Let's sprinkle the above with some error handling and propagation, in a pretty dumb way.

const getOne = callback => api.fetch('/one', callback);
const getTwo = callback => api.fetch('/two', callback);
const renderCombined = (onSuccess, onFail) => {
try {
getOne((responseOne, error) => {
if (error) return onFail(error);
try {
getTwo((responseTwo, error) => {
if (error) return onFail(error);
try {
// code combining two responses
// const combined = ...
// ...
render(combined);
} catch (e) {
onFail(e)
}
})
} catch (e) {
onFail(e);
}
})
} catch (e) {
onFail(e);
}
}

Just look at this unreadable mess. I admit, I've exagerrated just a wee bit, but I think you can see my point by now. It gets truly ridiculous if you have significant number of asynchronous actions that depend on each other.

Promises

This problem just needed to be solved in one way or another. If only we had something that could chain asynchronous functions and propagate errors properly... oh wait. Promises are one of the best ways to deal with it. Let's take a look at what makes them a good solution.

Then

Promises then function is propably the most famous one. It can take(but doesn't have to) two functions as arguments - onFulfilled and onRejected. We usually omit onRejected function(more on this later).

When the first Promise resolves, then is passing the resolved value to the function we've supplied and returns a new Promise.

Let's rewrite our first asynchronous example with Promises, assuming that api.fetch now returns a Promise.

// Callback way
const getAndLog = endpoint => {
// asynchronous api.fetch(endpoint, [,callback(response)]);
const response = api.fetch(endpoint, response => console.log(response));
}

Becomes

// Promise way
const getAndLog = endpoint => {
// asynchronous api.fetch(endpoint);
api.fetch(endpoint).then(console.log);
}

All

Remember the combining two api calls thing? Promises give us a very interesting way of dealing with this case. Assuming that the second api call doesn't need any information returned by the first api call, we can use all function to resolve them both at the same time. Values resolved can be accessed later inside then function.

// Callback way
const getOne = callback => api.fetch('/one', callback);
const getTwo = callback => api.fetch('/two', callback);
const renderCombined = render => {
getOne(responseOne => {
getTwo(responseTwo => {
// code combining two responses
// const combined = ...
// ...
render(combined);
})
})
}

Becomes

// Promise way
const getOne = () => api.fetch('/one'); // api.fetch() returns a Promise
const getTwo = () => api.fetch('/two'); // api.fetch() returns a Promise
const renderCombined = render => {
// a list of api calls to be made
const promises = [getOne(), getTwo()];
// Promise.all takes in a list of api calls
Promise.all(promises)
.then(([responseOne, responseTwo]) => {
// this function is called when both api calls are finished
// now we can combine responseOne and responseTwo
// const combined = ...
return combined;
})
.then(combined => render(combined)) // and render the result
}

Catch

But what about error propagating? I have enough decency not to copy the error propagating monstrosity we've defined earlier. Believe it or not but we need to add only one line of code to achieve the error propagation we've defined.

// Promise way
const getOne = () => api.fetch('/one');
const getTwo = () => api.fetch('/two');
const renderCombined = (onSuccess, onFail) => {
const promises = [getOne(), getTwo()];
Promise.all(promises)
.then(([responseOne, responseTwo]) => {
// code combining two responses
// const combined = ...
return combined;
})
.then(combined => onSuccess(combined))
.catch(reason => onFail(reason)); // error handling
}

Conclusion

Promises are about asynchronous function composition and proper error propagation. Knowing how we can use Promises for our advantage when dealing with asynchronous code leaves us with one question. But how? How in the world do Promises do that? If you're interested in how the internals of a Promise look, I highly encourage you to read my recent write up on this topic.