February 03, 2020
Author: Kamil Biel · 10 minute read
⭠ BACK TO POSTSThis 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.
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 callconst response = api.fetch(endpoint);// logs reponseconsole.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.
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.
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.
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.
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 wayconst getAndLog = endpoint => {// asynchronous api.fetch(endpoint, [,callback(response)]);const response = api.fetch(endpoint, response => console.log(response));}
Becomes
// Promise wayconst getAndLog = endpoint => {// asynchronous api.fetch(endpoint);api.fetch(endpoint).then(console.log);}
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 wayconst 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 wayconst getOne = () => api.fetch('/one'); // api.fetch() returns a Promiseconst getTwo = () => api.fetch('/two'); // api.fetch() returns a Promiseconst renderCombined = render => {// a list of api calls to be madeconst promises = [getOne(), getTwo()];// Promise.all takes in a list of api callsPromise.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}
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 wayconst 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}
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.