Reactivity In Actions

antischematic

Michael Muscat

Posted on September 6, 2022

Reactivity In Actions

In Angular State Library actions are everything. And everything is an action.

Disclaimer: This project is an experiment, it is not production ready.

@Store()
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [Counter.Provide(UICounter)],
  selector: 'ui-counter',
  template:
    <p>UICounter: {{ count }}</p>
    <dd>
      <ng-content></ng-content>
    </dd>
})
export class UICounter {
  @Input() count = 0;
  @Output() countChange = new EventEmitter<number>(true);

  @Invoke() autoIncrement() {
    this.count++;
    this.countChange.emit(this.count);

    return dispatch(timer(1000), {
      next: this.autoIncrement,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The secret to reactivity in Angular is knowing when to run change detection. By default, Angular runs change detection for us automatically every time we interact with the DOM or do some async work. This magical reactivity is what sets Angular apart from the crowd. But it comes at a cost. Performance.

Pushing Problems

As an Angular application grows in size and complexity, we start running into problems. The more components we render, the slower each render cycle gets. This is because Angular dirty checks every data binding in every component every time change detection runs. How often does change detection run? A lot.

The root cause of this slow down is Zone.js. On every user interaction, microtask (eg. promises) and macrotask (eg. timers), Zone.js lets Angular know that it should run change detection in case some data binding in the app has changed. We have a budget to keep of 16ms (60Hz) or 8ms (120Hz) so that the user experience remains fluid. Angular's change detection cycle can easily exceed this limit.

To manage this problem, Angular says we should configure components to use the OnPush change detection strategy.


The OnPush Problem


The OnPush strategy tells Angular that this component's change detection tree should only be checked:

  1. When @Input bindings change.

  2. When an (event) binding emits.

  3. When change detection is run manually.

In the example above we can see that the components rendered inside the OnPush component do not update when the counter changes. Since there is no input binding and no template event, that leaves only one option.

Manual Change Detection

At some point we will need to call markForCheck. This marks the view dirty so that Angular will check the component and update its host bindings and template bindings if necessary. There are two common approaches:

Async Pipes

Most Angular apps use AsyncPipe, which calls markForCheck internally. This in my opinion creates more problems than it solves, due to the cognitive load of async behaviour in templates plus the non-intuitive nature of RxJS. If a developer isn't familiar with the differences between unicast/multicast, and hot/cold observables, they're going to have a bad time.

Manual Subscribe

Subscribing in components is often frowned-upon, but it's a lot easier than using AsyncPipe, especially for callbacks.

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template:
    <div>{{ data | json }}</div>
    <button (click)="loadData()">
      Load Data
    </button>
})i
export class UIData {
  private http = inject(HttpClient)
  private changeDetector = inject(ChangeDetectorRef)

  data

  loadData() {
    this.http.get(endpoint).subscribe((data) => {
      this.data = data
      this.changeDetector.markForCheck()
    })
  }
}
Enter fullscreen mode Exit fullscreen mode
What about subscription management?

Of course, we still need to know when and how to manage long-lived subscriptions to prevent memory leaks.

Square Peg, Round Hole

Both approaches to manual change detection are lacking. This is because RxJS and Angular are built on fundamentally different paradigms. RxJS is functional and composable, while Angular components are object-oriented and imperative.

Square peg round hole

Putting RxJS into Angular templates

There are many existing solutions to this problem, but all come at the cost of increased complexity. We can offload state to services, but now we need a way to connect the service to our component. We can offload state to redux, but now we have to manage several layers of indirection. Why can't state management be simpler?

Observable Core, @Directive Shell

Hat tip Destroy All Software

Angular State Library solves the OnPush problem, combining Vue-style reactivity, RxJS and Redux. It also solves the square peg/round hole problem by keeping state management imperative on Angular's side while extracting RxJS into functional-composable side effects. Effects are then mapped back onto the component's state using imperative callbacks (ie. action reducers).

Any directive can be converted into a redux store without added layers of indirection and boilerplate. The possibilities are endless.

Reactivity in Action


The secret to reactivity is observable events. All events generated by Angular State Library are dispatched to a global event stream. All events are generated by actions.

  1. The event stream is populated by calls to @Action methods
  2. @Input changes are also tracked as events via ngOnChanges.
  3. The event stream captures the next, error and complete events from dispatched effects.
  4. Components connect to a store by providing a StoreToken.
  5. The StoreToken subscribes to the store's event stream and triggers markForCheck to update the view when an event is emitted.
  6. The StoreToken returns the store's state as a reactive proxy object for tracking in other actions and selectors.

Now we can inject stores anywhere without worrying about OnPush or subscriptions. It just works.

Help Wanted

This project is currently a proof of concept. It's no where near production ready. If you are interested in contributing or dogfooding feel free to open a line on Github discussions, or leave a comment with your thoughts below.

Thanks for Reading!

💖 💪 🙅 🚩
antischematic
Michael Muscat

Posted on September 6, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related