RFC: Why Angular needs a composition API
Michael Muscat
Posted on September 5, 2021
Angular is an opinionated framework, but leaves open the question of how state should be managed in our application. Out of the box we are presented with a mix of imperative and reactive styles for state management, which is a barrier to entry for purely reactive state.
A composition API solves this by filling in the gaps in Angular's reactive model, providing a consistent pattern for reactive state management.
State
Fig 1a. Imperative style
@Component()
export class MyComponent {
@Input()
count = 0
handleCountChange() {
// do something with count
}
ngOnChanges(changes) {
if (changes.count) {
this.handleCountChange()
}
}
}
Fig 1b. Reactive composition
function setup() {
const count = use(0)
subscribe(count, () => {
// do something with count
})
return {
count
}
}
@Component({
inputs: ["count"]
})
export class MyComponent extends ViewDef(setup)
These two examples might look similar, but the latter example has a few advantages already:
We can observe changes to the value of
count
, even it's an input or not.We can extract the logic and side effect into another function, which is not straightforward with the first example.
Fig 1c. Extraction
function useCount(value) {
const count = use(value)
subscribe(count, () => {
// do something with count
})
return count
}
function setup() {
const count = useCount(0)
}
@Component({
inputs: ["count"]
})
export class MyComponent extends ViewDef(setup)
Subscriptions
Subscriptions are another pain point that Angular leaves us to figure out for ourselves. Current approaches in the ecosystem include:
Declarative
Out of the box Angular gives us a pipe that automatically handles subscriptions to observable template bindings.
Fig 2. Async pipe binding
<div *ngIf="observable$ | async as value"></div>
The benefits of this approach is that we do not have to worry about the timing of the subscription, since it will always happen when the view is mounted, and the view will be updated automatically when values change.
However in real world applications it is easy to accidentally over-subscribe to a value because you forgot to share()
it first. Templates with many temporal async bindings are much harder to reason about than static templates with synchronous state.
Imperative
Another popular approach is to subscribe to observables in our component class, using a sink to simplify subscription disposal.
Fig 3. Subscription sink with imperative subscribe
@Component()
export class MyComponent {
count = 0
sink = new Subscription
ngOnDestroy() {
this.sink.unsubscribe()
}
constructor(store: Store, changeDetectorRef: ChangeDetectorRef) {
this.sink.add(
store.subscribe(state => {
this.count = state.count
changeDetectorRef.detectChanges()
})
)
}
}
Sinks are a good way to deal with imperative subscriptions, but results in more verbose code. Other approaches use takeUntil
, but that has its own pitfalls. The only guaranteed way to dispose of a subscription is to call its unsubscribe
method.
The downside to this approach is we have to manually handle change detection if using the OnPush
change detection strategy. The timing of the subscription here also matters, causing more confusion.
Let's see how composition solves these problems.
Fig 4. Composable subscriptions with reactive state
function setup() {
const store = inject(Store)
const count = use(0)
subscribe(store, (state) => count(state.count))
return {
count
}
}
@Component()
export class MyComponent extends ViewDef(setup) {}
<div *ngIf="count > 0"></div>
The composition API runs in an Execution Context with the following behaviour:
Subscriptions are deferred until the view has mounted, after all inputs and queries have been populated.
Change detection runs automatically whenever a value is emitted, after calling the observer. State changes are batched to prevent unnecessary re-renders.
Subscriptions are automatically cleaned up when the view is destroyed.
Reactive values are unwrapped in the component template for easy, synchronous access.
Lifecycle
The imperative style of Angular's lifecycle hooks work against us when we want truly reactive, composable components.
Fig 5. A riddle, wrapped in a mystery, inside an enigma
@Component()
export class MyComponent {
ngOnChanges() {}
ngOnInit() {}
ngDoCheck() {}
ngAfterContentInit() {}
ngAfterContentChecked() {}
ngAfterViewInit() {}
ngAfterViewChecked() {}
ngOnDestroy() {}
}
The composition API provides a Layer of Abstraction so we don't have to think about it.
Fig 6. Composition API lifecycle
function setup() {
const count = use(0) // checked on ngDoCheck
const content = use(ContentChild) // checked on ngAfterContentChecked
const view = use(ViewChild) // checked on ngAfterViewChecked
subscribe(() => {
// ngAfterViewInit
return () => {
// ngOnDestroy
}
})
return {
count,
content,
view
}
}
@Component()
export class MyComponent extends ViewDef(setup) {}
Fine tune control is also possible using a custom scheduler.
Fig 7. Before/After DOM update hooks
function setup(context: SchedulerLike) {
const count = use(0)
const beforeUpdate = count.pipe(
auditTime(0, context) // pass 1 for afterUpdate
)
subscribe(beforeUpdate, () => {
// after count changes, before DOM updates.
})
}
@Component()
export class MyComponent extends ViewDef(setup) {}
Change Detection
Angular's default change detection strategy is amazing for beginners in that it "just works", but not long after it becomes necessary to optimise performance by using the OnPush
strategy. However in this change detection mode you must manually trigger change detection after an async operation by calling detectChanges
somewhere in your code, or implicitly with the async
pipe.
By comparison, the composition API schedules change detection automatically:
- Whenever a reactive input changes
- Whenever a reactive value returned from a
ViewDef
emits - Whenever a subscribed observable emits
- With or without zone.js
Fig 8. Composition API change detection
function setup(context: Context) {
const count = use(0)
subscribe(interval(1000), () => {
// reactive change detection
})
return {
count // reactive change detection
}
}
@Component({
inputs: ["count"] // bound to reactive input
})
export class MyComponent extends ViewDef(setup) {}
Changes to reactive state are batched so that the view is only checked once when multiple values are updated in the same "tick".
Angular Composition API
This RFC includes a reference implementation. Install it with one of the commands below. Currently requires Angular 12.x with RxJS 6.x.
npm i @mmuscat/angular-composition-api
yarn add @mmuscat/angular-composition-api
Built for Ivy
Angular Composition API wouldn't be possible without the underlying changes brought by the Ivy rendering engine.
Built for RxJS
Other libraries achieve reactivity by introducing their own reactive primitives. Angular Composition API builds on top of the existing RxJS library. The result is a small api surface area and bundle size. You already know how to use it.
Built for the future
There is currently talk of adding a view composition API to a future version of Angular. It is hoped that this library can provide inspiration for that discussion and potentially integrate with any new features that might bring.
Request for Comment
If you are interested in improving this proposal, leave a comment in this Github issue. Alternatively, you can try out the reference implementation from the links below.
Angular Composition API on Stackblitz
Angular Composition API on Github
Angular Composition API on NPM
Prior Arts
Posted on September 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024