antischematic / angular-composition-api

Composition model for functional reactive Angular applications.
https://mmuscat.github.io/angular-composition-api
46 stars 0 forks source link

RFC: Angular Composition API #9

Closed antischematic closed 3 years ago

antischematic commented 3 years ago

RFC: Angular Composition API

This topic is an open discussion for the current and future development of Angular Composition API. If you have an issue please open a separate thread.

Why Angular needs a composition API

State

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.

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:

  1. We can observe changes to the value of count, even it's an input or not.

  2. We can extract the logic and side effect into another function, which is not possible 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:

  1. Declaritive

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.

  1. 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:

  1. Subscriptions are deferred until the view has mounted, after all inputs and queries have been populated.

  2. Change detection runs automatically whenever a value is emitted, after calling the observer. State changes are batched to prevent uneccessary re-renders.

  3. Subscriptions are automatically cleaned up when the view is destroyed.

  4. 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 the Context scheduler.

Fig 7. Before/After DOM update hooks

function setup(context: Context) {
   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:

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".

How composition works

TBD

Execution context

TBD

Reactive blocks

TBD

Functional groups

TBD

Change detection

TBD

Angular Composition API

This RFC includes a reference implementation. Install it with one of the commands below.

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.

Prior Arts

React Hooks

Vue Composition API

Angular Effects

DmitryEfimenko commented 3 years ago

Let me start off by saying that I have much respect for the effort being put in here.

However, after working with React hooks for quite a some time, I came to really dislike the whole "useWhatever" hooks approach for state management. I feel that using straight up RxJS gives much more flexibility. The subscribe method that I see in the proposed solution looks like just a glorified combineLatest. Maybe I don't understand something, but it looks like this leaves out the flexibility for data flow control of other operators available from RxJS.

Perhaps this is not the most objective feedback, but rather my initial feelings.

antischematic commented 3 years ago

@DmitryEfimenko Thank you for your feedback. To clarify, the proposed API is designed to work in tandem with RxJS specifically.

On the surface you can consider the following two snippets to be conceptually equivalent.

Plain RxJS

const value = new BehaviorSubject(0)
value.subscribe((current) => {
  console.log(current)
})

Composition API

const value = use(0) // uses BehaviorSubject under the hood, returns an interop observable. Interchangeable.
subscribe(value, (current) => {
  console.log(current)
})

The problems that subscribe attempts to solve are:

There are some use cases where it might not be appropriate to use the subscribe API. You can still have full control by using plain RxJS.

const value = new BehaviourSubject(0)
const sink = subscribe()

sink.add(value.subscribe((current) => {
  console.log(current)
}))

To your last point about data flow control, subscribe makes no assumptions about the type of observable it is given. You can use pipeable operators however you like to achieve the desired result.

chaosmonster commented 3 years ago

I agree with @DmitryEfimenko

If I jump into the role of a PO my biggest question is what do you gain from this? The proposed code is just equivalent to Vue 3's composition API which doesn't provide any benefit for me as a developer in Angular or at least you didn't sell it to me? Is it more performant? Is it reducing the bundle size? Does it make testing simpler? Does it reduce complexity?

To extend on that vue 3 introduced the composition API to manage code reuse patterns, typescript support and readability or large components.

TypeScript is not an issue in Angular as it is a first class citizen. Reuse Patterns exist in Angular with DI and the service concept. Next to that you can always use mixins (see Angular material). Lastly if you follow the patterns suggested by Angular your readability is (subjectively) just fine as it is. Also keep in mind that Angular as it is class based and uses decorator does not have the limitation vue 2 had in regards to code organization within the class. We don't have to differentiate between data, methods and so on. One could change the linter rules and combine the properties and methods as they like (though at least in my opinion I wouldn't like to read that)

Lastly:

I really like to look beyond the tellerand and see what other libraries and frameworks do. But not everything needs to be copied, as it is often a result of a problem that occured. And one should always as themself do we have that problem or do we have a different approach to solve that problem already in place?

antischematic commented 3 years ago

@chaosmonster Good questions, I will try to answer briefly.

antischematic commented 3 years ago

I've added a Sierpinski triangle bench demo. Refer to this issue for perf comparisons.