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.
*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.
Promise is the fundamental building-block of asynchronous code. The
Promise interface is fairly straightforward:
Promises have just two methods,
.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
Promises 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.
Because of the way
Promises 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
Pipes 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
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)
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
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.
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
*ngIfs 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
async pipe is a powerful tool, which allows for the unwrapping of
Observables 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.