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
.
.bind
also 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
function
functions 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
gotPup1
and 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
then
calls – 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
.then
appears (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
.then
calls or callback functions) - synchronous-looking code with access to previous results in scope
The cost is pretty minimal:
- the
await
keyword may only be used inside anasync
function, 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.