Intro
By now, promises are well-established in the JS ecosystem, not only being officially specified in ECMAScript, but even having a first-class syntactic sugar in the form of async functions.
When learning promises, many JS developers are told that a major advantage of promise chaining is that it keeps the code "flat", avoiding the pyramid of doom of nested callbacks. While this is partly true, it also puts undue emphasis on code appearance, running the risk of missing the point.
True "callback hell" is less about indentation – in fact, by naming callback functions and factoring them out to the top level, one can often flatten out async code without the need for promises. Instead, callback hell is when we lose the composable vanilla function API (pass in data, receive result), where returned values can be bound to variables, aggregated in collections, passed to other functions, and combined in first-class ways.
All of this preamble is to give context to the following statement: nesting promises is often an antipattern, but not always. In fact, there is a common situation in which a little nesting can make perfect sense, though there exist several alternatives. This short article will demonstrate a common scoping issue with promises and multiple solutions for that issue.
The Setup
For these examples, we will imagine that the function getPuppyById is an AJAX method returning some data via a promise. Puppies will be objects with a bestFriend foreign key to another puppy:
{
id: 4, // this puppy's id
name: 'Mr. Wiggles', // this puppy's name
bestFriend: 17 // id of this puppy's best friend (another puppy)
}
If we wish to fetch puppy #1's best friend's name, we can chain calls to getPuppyById:
This works just fine when our early results are just discardable steps towards our desired final result.
The Problem
However, what if we wanted to produce a promise for both puppies' names – the original and the friend? Because the callback passed to then introduces a function scope, the first puppy may no longer be in scope further down the chain.
There are multiple ways to solve this, which we will examine in a moment. Before we do so, go ahead and fix the above code snippet using whatever technique you may prefer. Only edit the top half of the snippet; you are trying to make twoPuppyNamesP fulfill its promise (hah) of delivering both puppies.
Solutions
Library-Specific: Bluebird bind
Before promises became official in ES2015, third-party implementations like Bluebird were popular. Bluebird is still used by some codebases for its speed and wide array of utility methods.
Though it breaks section 2.2.5 of the A+ promise spec to do so, Bluebird includes a special feature in which you can set the this value of a promise chain – providing a shared mutable namespace in which to save intermediate results. The specific method is named bind.
.bindalso has a useful side purpose - promise handlers don't need to share a function to use shared state
While this works, it has significant drawbacks:
- it complicates the promise chain with spec-breaking features
- it requires using
functionfunctions to accessthis - it is non-portable knowledge tied to a specific library
A+-Compliant, ECMA-Approved: Promise.all
If only we could pass multiple values down through a promise chain – even when one of those values is a pending promise, whose value we wish to access further down the chain.
Of course, we do not need to wish for such a feature, as it is available via the Promise.all static method. By returning an array of both synchronous values and promise values, wrapped in a call to all, we get access to an array of synchronous values in the next then.
Even though the array passed to .all has a mix of normal and promise values, the resulting overall promise is for an array of normal values.
This strategy will work in any setting that supports ES2015, and is thus much more portable than the Bluebird bind trick. Unfortunately, it too has cons:
- more verbose return lines
- more complex function parameters and destructuring
- as the chain grows, passing down multiple results does not scale well
- overall, a lot of redundant "plumbing" of early values through the chain
Controlled State, Shared Scope
We now come to one of the most common and viable techniques for sharing state through a promise chain – use a mutable or reassignable variable(s) in a higher scope. As each handler in a then chain is invoked, it will set and/or read the values of a shared let binding or the properties of a shared object.
This may seem "illegal" considering how we normally consider async code to work, but in fact it is guaranteed to work as expected as later callbacks in a then chain can only be invoked after earlier callbacks. So the usage of pup1 in the second then will work because pup1 is guaranteed to have been assigned in the callback of the previous then.
This has some distinct advantages:
- it is relatively clear even for people without advanced knowledge of promises
- it is setting-agnostic
- it is relatively light on syntax
- the chain remains flat, reducing mental load
As always, there are still tradeoffs to consider, however.
- shared mutable state is risky; care should be taken to only allow the promise chain to read or modify these variables
- reading outside the chain is not guaranteed to work due to indeterminate timing
- writing outside the chain can break guarantees within the chain
- we now need two versions of the variable name – a parameter name like
gotPup1and a shared state variable likepup1– to avoid shadowing
If the promise chain is itself contained within a short function scope, disciplined use of shared state in a local setting can be concise and easy way to solve the issue of passing information down the chain.
The Punchline: Nested Promises
This article opened with the promise (hah) of showing a situation in which a small bit of nesting can be a valid and useful technique. The key point is that with a nested chain, an inner then still has scope access to the results from an outer then.
In such cases, it is crucial to remember to return the nested promise chain to the parent promise chain. In the example above we use the implicit return of an arrow function to accomplish this, but it is a common error to forget the return keyword when in a bracket-enclosed function body.
The biggest advantage that the above pattern has over an outer-scope variable is that it is stateless – there is no explicit mutation occurring in the visible code, only a declarative sequence of functional transformations.
As always, we can identify some disadvantages:
- this approach does not scale well for passing down each result from many
thencalls – one quickly returns to the "pyramid of doom" for such cases - with nesting comes increased mental load in parsing and understanding the logic of the promise chain
- as is often the case with promise chains, it can be especially difficult to decide on a sensible formatting scheme with respect to where
.thenappears (same line? next line? indented?) and where to position the callback function
Silly Experiment: Formatting Tricks
Speaking of formatting, there is no reason why one cannot format a nested promise chain in a "flat" way, if we allow for piling up of parentheses:
The longer the nested chain, the more we defer closing parens to the last line, where they will pile up like afterthoughts. In a language like Haskell in which function application doesn't use parens, this isn't a problem! But for JavaScript, it gets a little silly. Compare and contrast:
-- Haskell
_then = (>>=) -- renaming for JS readers; can't use 'then' b/c it's a keyword
pupsIO =
getPuppyById 1
`_then` \pup1 -> getPuppyById (bestFriend pup1)
`_then` \pup2 -> getPuppyById (bestFriend pup2)
`_then` \pup3 -> getPuppyById (bestFriend pup3)
`_then` \pup4 -> getPuppyById (bestFriend pup4)
`_then` \pup5 -> pure [pup1, pup2, pup3, pup4, pup5]
// JavaScript
const pupsP =
getPuppyById(1)
.then(pup1 => getPuppyById(pup1.bestFriend)
.then(pup2 => getPuppyById(pup2.bestFriend)
.then(pup3 => getPuppyById(pup3.bestFriend)
.then(pup4 => getPuppyById(pup4.bestFriend)
.then(pup5 => [pup1, pup2, pup3, pup4, pup5]))))) // lol
The Promised Land: Async/Await
Moving past our promise chain woes, we return to the real issue at hand – promise chains are composed from callback functions, and functions syntactically introduce new scopes. If we didn't have sibling scopes, we could share access to previous results.
Lo and behold, this is one of the problems solved by async functions.
The advantages are substantial:
- far less noise (no
.thencalls or callback functions) - synchronous-looking code with access to previous results in scope
The cost is pretty minimal:
- the
awaitkeyword may only be used inside anasyncfunction, so we need to wrap our promise code in a function body
Async/await is analogous to Haskell's do-notation, where do is like async and <- is like await:
-- Haskell
twoPuppyNames = do
pup1 <- getPuppyById 1
friend <- getPuppyById (bestFriend pup1)
pure [name pup1, name friend]
One major difference is that async/await in JS is only for promises, whereas Haskell's do notation works with any monad.
Conclusion
With the advent of async/await, programmers are using raw promise chains less often. Async/await has its own subtleties to master, but it neatly solves at least one awkward aspect of promise chains, namely accessing previous async results in a sequence of operations.
As the title to this article suggested, when writing a manual promise chain it is sometimes perfectly valid to use a little local nesting. Doing so keeps multiple results in scope, without needing special library tricks or stateful assignments.
In any case, I hope that these examples will help people learning JS promises to understand them a little better and use them more confidently.
Top comments (3)
NOTE: on 2020-08-13, github.com/forem/forem/issues/9773 noted that some Runkit embeds are failing to load correctly. This article is affected; apologies to any readers who may come here before that issue is resolved.
Great article, helped me understand promises a little more. Unfortunately, I can't see some of the images. It comes up with the error "Unable to load embed. Syntax error found in Preamble. See console for error."
Sorry about that! See dev.to/glebec/comment/14255 – in short, there is currently a problem with Runkit support on Dev.to.