Understanding the Intricacies of Angular’s Async Pipe
Corey Woodfield
Reading time: about 9 min
Topics:
The Naive Approach to Using Angular's Async Pipe
When I was still fairly new to doing frontend development with TypeScript and Angular, I was tasked with building a component in Lucidchart that would show up conditionally. This seemed like a great opportunity to familiarize myself with Angular’s async
pipe. The component displays the status of the current document, such as draft, in review, rejected, etc., and allowed users to change it. Here's what the dropdown looks like on a document with the "DRAFT" status.
Because document status isn't a core feature, and is not necessarily important in every user's workflow, this dropdown only shows up on documents that already have a status. When a document doesn't have a status, the dropdown is not shown, and the user can set a status from the File menu.
As part of loading the editor, document information is retrieved from servers. This information includes the id of the status assigned to the document, if any. While there are some general status definitions, such as “DRAFT” or “COMPLETED,” accounts with more specific workflows can have custom status definitions, such as “READY FOR REVIEW” or “APPROVED”. In order to correctly display the available options, I needed to get more information about statuses available to the user. Thus, in the code, I had an id for the status associated with a document, and a Promise
which would hold the status definitions associated with a user's account. The angular code that I ended up writing looked something like this:
<lucid-drop-down
*ngIf="hasStatus()"
[options]="definitionsPromise | async"
></lucid-drop-down>
I checked whether the document had a status synchronously (hasStatus()
returned false until definitionsPromise
resolved, and then it would return whether the document currently had a status or not), and I passed the definitionsPromise
through Angular's async
pipe to unwrap the asynchronous value. I figured that since hasStatus
implicitly depended on the resolution of definitionsPromise
, that when hasStatus
returned true, I could be sure the menu options were ready. And that's where I was wrong.
Digging deeper
To understand what was wrong with this approach, there are a few important elements to note about how Angular and Javascript work.
Angular's *ngIf
*ngIf
is a built-in tool that Angular provides, and it is quite powerful when building dynamic web apps. Here's how it works: when the condition is true, the conditional HTML is added to the DOM and rendered, and when the condition is false, the relevant elements are removed from the DOM entirely. There are cases where you might want to hide an element with CSS instead, but in the large majority of cases, *ngIf
is both simpler and more performant.
Asynchronous execution in Javascript
In Javascript, the Promise
is the fundamental building-block of asynchronous code. The Promise
interface is fairly straightforward: Promise
s have just two methods, .then
and .catch
. Both of these methods take callbacks as parameters. If the Promise
resolves successfully, any callbacks passed to .then
are run, and if the Promise
is rejected or has an error when being processed, callbacks passed to .catch
are executed.
What I've found to be the most common “gotcha” of using Promise
s is that any code in a .then
callback is guaranteed to be asynchronous. When a Promise
is resolved (or, if you create a completed Promise
with the static Promise.resolve()
method), all callbacks on the Promise
are put at the end of the microtask queue. So no matter what, code after a call to .then
will be run before the code inside of the call to .then
. The short answer to "How do I get a value out of a completed Promise
synchronously?" is that you can't.
Angular's async
pipe
Because of the way Promise
s work, Angular's async
pipe has to be impure (meaning that it can return different outputs without any change in input). The transform
method on Pipe
s is synchronous, so when an async
pipe gets a Promise
, the pipe adds a callback to the Promise
and returns null. When the Promise
resolves and the callback is called, the async
pipe stores the value retrieved from the Promise
and marks itself for check. Then, when change detection occurs again, the transform
method will be called with the same Promise
and the pipe returns the value that it got out of that Promise
.
While not the main focus of this article, another important note here is that the async
pipe will only return the value retrieved from the Promise
if the input is still the exact same Promise
instance. If you're calling an async
method, or a method that creates a new Promise
every time it's called, the async
pipe will assume that any value it has is no longer relevant and thus will likely never produce a usable value.
Hopefully I’ve been able to shed some light on how the code presented at the beginning of this post had some problems.
Symptoms of my naivete
The code I wrote to test this component should've been the first clue that something was off—the first iteration of the test looked like this:
await setupComponentForTest();
fixture.detectChanges();
await Promise.resolve(); // <--- CODE SMELL!
fixture.detectChanges();
There are some cases where await Promise.resolve()
has a legitimate use, but in most cases, it's an indication that something in your code is a bit fishy. If there's something specific you need to happen before your code runs, you should await that specific thing, clarifying the intent of the code. If you can't refactor the code to wait on a specific Promise
, then there's probably an implicit temporal dependency in your code that shouldn't exist.
In this case, I was waiting for the definitions Promise
to resolve before my code ran, but it wasn't enough. All the setup took place, and only then, after the definitions were available and the condition in the *ngIf
returned true, was the async
pipe created at all. So, the first time change detection ran after everything I needed was available, the async
pipe returned null, as it needed to wait on the (already resolved) Promise
. The async
pipe would process the actual value after its .then
callback got to the front of the queue. In order to ensure the test code ran after the value was available, I had to put the remainder of the test at the end of the microtask queue, and then detect changes again, after the definitions made it through the async
pipe.
This also caused the actual code for the component to have unexpected behavior. The lucid-drop-down doesn't expect to get null as the value for options, it expects an array that has the available options in it. The options field wasn't designed to be optional (as it's integral to the functionality of the component) and I, naively, hadn't initially expected what I was passing in to be null.
The fix
Eventually, after spending an inordinate amount of time putting band-aids over my bad code, I refactored it to be more sensible, and to remove various baked-in assumptions. Here's what the component looked like after the refactor:
<ng-container *ngIf="definitionsPromise | async as options">
<lucid-drop-down
*ngIf="hasStatus()"
[options]="options"
></lucid-drop-down>
</ng-container>
Without understanding what *ngIf
and the async
pipe actually do, this might seem like it's no different than the first implementation, when you take into account that hasStatus()
relies implicitly on definitionsPromise
having resolved. While the differences may seem trivial, they are key to the component functioning correctly.
One key difference is that the async
pipe isn't hidden behind an *ngIf
. In the first iteration of the code, the async
pipe wouldn't get created until hasStatus()
was true, and thus, when we wanted to use the value in it, we had to flush null out and then wait for the actual value. Now, the async
pipe is created as soon as the containing component is created, and the null that comes out of the async
pipe is handled naturally by the *ngIf
in the ng-container. After definitionsPromise
has resolved, we use the handy as syntax available in *ngIf
s to store the value retrieved from the Promise
as options. Within the ng-container, we can be sure options is defined, so we can freely pass it as an input to the lucid-drop-down without any problems.
I was also able to clean up the test code after this, changing it to the following:
fixture.detectChanges(); // get null out of the async pipe
await setupComponentForTest();
fixture.detectChanges(); // get the ng-container and the dropdown to show up
I still needed two calls to detectChanges, because the async
pipe still needed to have the null value flushed out, but I no longer needed to wait on a Promise.resolve()
.
Overall, Angular's async
pipe is a powerful tool, which allows for the unwrapping of Promise
s and Observable
s without needing to write a lot of boilerplate code. It's important to keep in mind, however, the limitations imposed by the environment, and how those will influence how the end result actually works.
About Lucid
Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.