Understanding the Intricacies of Angular’s Async Pipe

Corey Woodfield

Reading time: about 9 min

Topics:

  • Web Development

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.

Example of a document with a 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: Promises 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 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.

Angular's async pipe

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 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 *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 Promise.resolve().

Overall, Angular's async pipe is a powerful tool, which allows for the unwrapping of Promises and 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.

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.

Solutions

  • Digital transformation
  • Cloud migration
  • New product development
  • Efficiency through AI
  • View more

Resources

  • Customers
  • Developers
  • Security
  • Support
  • Training labs
  • User community
  • Partners
  • Newsletter
PrivacyLegalCookie privacy choicesCookie policy
  • linkedin
  • twitter
  • instagram
  • facebook
  • youtube
  • glassdoor
  • tiktok

© 2024 Lucid Software Inc.