“Seeing” Angular Change Detection in Action Part II: OnPush, Observables, and the Async Pipe

In my last change detection post, we looked at an Angular demo where the central component rendered its own component tree as a literal tree graph, with nodes and edges and whatnot. That demo allowed us to visually follow the change detection system’s path through an actual component tree as it searched for and detected changes.

In this post, we’ll pick up where we left off and use a couple of small Angular apps to look at some high-level ideas undergirding the OnPush change detection strategy. You’ll learn how the change detection system traverses a tree of components using the OnPush change detection system. You’ll also see how the Observables-plus-async-pipe duo helps us manage the task of dirtying out-of-date components.

Searching the component tree

You may have noticed in the last blog post that once change detection is triggered, the default behavior is to do a depth-first search through the entire component tree, searching out changes. The appeal of setting our component’s change detection strategy to OnPush is performance—it narrows the change detection system’s search space to branches of the component tree that are more likely to be out of date.

We can see this behavior in action in the demo below. Again, we have a
component rendering its own component tree as a literal tree graph. (If you browse the component templates and start inspecting the DOM with your dev tools, you will find the graph reflected in the DOM’s structure.) All of the components use ChangeDetectionStrategy.OnPush.

Link to the un-embedded version

Clicking a graph node marks the component rendering that node as out-of-date or, rather, “dirty” and a change detection cycle ensues. (We will discuss this and other change detection triggers for OnPush components in the next section.) Go ahead and click a few of the component nodes; when you do, you will see that the component tree highlights certain nodes with a flash of green. These flashes indicate that the node was visited by the change detection system and checked for changes.

Hopefully, this illustration underscores how much more efficient the change detection system is when searching through a tree of OnPush components. It still follows a depth-first search—same as the default change detection search behavior—but sticks to branches of the tree that contain components marked as dirty. It also stops once it reaches the deepest dirty node.

Marking components dirty

There is a trade-off, however, to the performance gains OnPush components give
us. When we use them, the framework makes the component developer partially responsible for letting it know about the out-of-date components by marking them dirty. This helps the framework know when it’s time to run change detection and also which set of branches in the component tree it needs to search.

There are three ways of dirtying a component. A component is marked dirty when:

  1. A DOM event bound in the component’s template is fired. (The demo in the last section relies solely on this method.)
  2. The component’s parent changes one of the component’s inputs.
  3. The component calls ChangeDetectorRef.markForCheck.

Dirty triggers 1 and 2 happen implicitly; the framework does the dirty marking for us. Trigger 3, however, is where we step in. When components become dirty for reasons outside of the scenarios described in 1 and 2, then markForCheck must be called. Calling markForCheck can be a burden because we often have to do it manually. (The official documentation has a simple example of manually calling markForCheck in a component that relies on an internal timer.) Angular does, however, try to mitigate some of this responsibility.

Observables and the async pipe

One of the most common scenarios in which a markForCheck is required is when
a set of components renders data from a shared injectable service. When one
component updates the service’s data, it puts the other components out-of-date. If they don’t get marked dirty, then after change detection finishes, their views still won’t reflect updates to the data. (Components failing to re-render is a common bug introduced by using OnPush components.)

One of Angular’s preferred solutions in this situation is using Observables and
the async pipe. This solution has two parts: First, we provide access to the
service’s shared data via getters that return Observables. Then in our
templates, we pipe these Observables through the async pipe. The async pipe
“observes” changes to the data and outputs the new values for rendering.1
Internally, the async pipe also calls markForCheck whenever there are
changes, putting the template’s component on the change detection system’s
search path.

In hello-world terms, this may look something like this:

@Injectable()
export class GreetingService {
    public greeting: BehaviorSubject = new BehaviorSubject('hello');

    public setGreeting(greeting: string) {
        this.greeting.next(greeting);
    }

    public getGreetingObservable(): Observable {
        return this.greeting;
    }
}
First we provide an observable view of our data.

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'hello',
  template: `

{{greetingService.getGreetingObservable() | async}}, world

`, styles: [`h1 { font-family: Lato; }`] }) export class HelloComponent { constructor(public greetingService: GreetingService) { } }
Then, we pipe the observable through the async pipe.

Let’s see this pattern in action:

Link to the un-embedded version

The example is a little contrived, but here we have two views that share a common injected service. The service, Matrix2x2Service, represents a two-by-two matrix. The views each represent two ways of editing the matrix’s entries. On the left, we have the Matrix2x2EntryEditorComponent, which allows for editing matrix entries with simple inputs, and on the right, we have the Matrix2x2DragEditorComponent, which allows for editing the matrix entries by dragging around the matrix’s column vectors.

Looking at the Matrix2x2Service service, we provide the getEntryObservable getter method, which returns an Observable that emits the most up-to-date value of an indexed matrix entry:

@Component({
export class Matrix2x2Service {
  private matrix: number[] = [
    1, 0,
    0, 1,
  ];
  private _entryObservables: BehaviorSubject[];
  private _observable: BehaviorSubject = new BehaviorSubject(this.matrix);

  constructor() {
    this._entryObservables = this.matrix.map((entry: number) => new BehaviorSubject(entry));
    combineLatest(this._entryObservables).subscribe(this._observable);
  }

  public getEntryObservable(rowIndex: number, columnIndex: number): Observable {
    return this._entryObservables[this.getEntryIndex(rowIndex, columnIndex)];
  }

  public get observable(): Observable {
    return this._observable;
  }

  public setEntry(rowIndex: number, columnIndex: number, value: number) {
    const i = this.getEntryIndex(rowIndex, columnIndex);
    this.matrix[i] = value;
    this._entryObservables[i].next(value);
  }

  private getEntryIndex(rowIndex: number, columnIndex): number {
    if (!this.isValidEntryIndex(rowIndex, columnIndex)) {
      throw new RangeError(`Invalid entry index (${rowIndex}, ${columnIndex})`);
    }
    return rowIndex * Matrix2x2Service.NUM_COLS + columnIndex;
  }

  private isValidEntryIndex(rowIndex: number, columnIndex): boolean {
    return (rowIndex >= 0 && rowIndex < Matrix2x2Service.NUM_ROWS) && (columnIndex >= 0 && columnIndex < Matrix2x2Service.NUM_COLS);
  }

  private static NUM_COLS = 2;
  private static NUM_ROWS = 2;
}
Click here to see the service in context.

Then, in the Matrix2x2EntryEditorComponent's template, we render those matrix
entries using the entry Observables and the async pipe:

<div *ngFor="let i of entryIndices">
	[
	<ng-container *ngFor="let j of entryIndices;">
		<input class="entry-input" [ngModel]="matrix.getEntryObservable(i, j) | async" (ngModelChange)="setEntry(i, j, $event)" />
	</ng-container>
	]
</div>
Click here to see the template in context.

The Matrix2x2DragEditorComponent similarly listens to the
Observables returned by the Matrix2x2Service and uses the async pipe to update its view.

Notice how as we interact with one component, the other component's view updates
accordingly. The duo of Observables and the async pipe turn the manual task of
calling markForCheck into one that happens implicitly, just like the other
dirty triggers.

A few miscellaneous benefits

As an aside, it’s worth pointing out a couple of other nice benefits that Observables and the async pipe offer as a pairing and individually:

  • The async pipe handles the job of managing Observable subscriptions. When a component is destroyed, the async pipe automatically unsubscribes.
  • Observable's wealth of "operators" (e.g., map, zip, filter, and other operators familiar to functional programming) lets us take a declarative approach to transforming updates on our data.
  • For Angular apps built atop a code base that uses alternative event libraries (e.g., Google closure's goog.events.EventTarget system), Observable's library, rxjs, provides an adapter API for turning them into Observables.

Conclusion

By making our components use the OnPush change detection strategy, we have been able to squeeze some additional performance out of our Angular apps. Hopefully this post has clarified some of the high-level ideas behind this practice and has given you some code to play with. For deeper dives into some of these areas, please see Ben Dilts’ post on OnPush performance and Sriraam Subramanian’s post on Observables in multi-view apps.

Footnotes

1 I like to think of think of this in terms of "wrapping" and "unwrapping": We wrap the shared data in an Observable and then unwrap it with the async pipe.

1 Comment

  1. I have been using your LucidChart for some years, and is an avid fan. I was thrilled to know that you use Angular for the UI. Your UI is very complex, so may require a state management on the client side. Do you use redux or NgRx or keep the state in the server so every state change is sent via an API to a DB in the server?

    Appreciate your feedback.

Your email address will not be published.