Reactivity In Actions
Michael Muscat
Posted on September 6, 2022
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,
});
}
}
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
strategy tells Angular that this component's change detection tree should only be checked:
When
@Input
bindings change.When an
(event)
binding emits.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()
})
}
}
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.
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.
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.
- The event stream is populated by calls to
@Action
methods -
@Input
changes are also tracked as events viangOnChanges
. - The event stream captures the
next
,error
andcomplete
events from dispatched effects. - Components connect to a store by providing a
StoreToken
. - The
StoreToken
subscribes to the store's event stream and triggersmarkForCheck
to update the view when an event is emitted. - 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!
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
November 30, 2024