stupidawesome / ng-effects

Reactivity system for Angular. https://ngfx.io
MIT License
46 stars 3 forks source link

Open discussion #1

Closed stupidawesome closed 4 years ago

stupidawesome commented 4 years ago

Continuing discussion from here.


Summary

The main goal of this implementation is to develop a reactive API for Angular components with the following characteristics:

  1. It does not complicate components with base classes.
  2. It extracts state management from the component into a separate service.
  3. It does not depend on lifecycle hooks.
  4. It shares the same injector as the component it is decorating.
  5. It automatically cleans up subscriptions when the component is destroyed.
  6. Any own property on the component can be observed and changed, including inputs.
  7. Component templates should be simple and synchronous.

Overview

The API takes inspiration from NgRx Effects and NGXS. This example demonstrates a component utilising various angular features that we would like to make observable:

  1. Input bindings
  2. Template bindings
  3. ViewChild (or ContentChild) decorators
  4. ViewChildren (or ContentChildren) decorators
  5. HostListener decorators
@Component({
    selector: "my-component",
    template: `
        <div (click)="event = $event" #viewChildRef>Test</div>
    `,
    providers: [effects(MyEffects)],
    host: { 
        "(mouseover)": "event = $event" 
    }
})
export class MyComponent {
    @Input() count: number

    @Output() countChange: EventEmitter<number>

    @ViewChild("viewChildRef") viewChild: ElementRef | null

    @ViewChildren("viewChildRef") viewChildren: QueryList<ElementRef> | null

    public event: Event | null

    constructor(connect: Connect) {
        this.count = 0
        this.countChange = new EventEmitter()
        this.viewChild = null
        this.viewChildren = null
        this.event = null

        connect(this)
    }
}

Binding the effects class is a three step process.

  1. effects(Effects1, [Effects2, [...Effects3]])

One or more classes are provided to the component that will provide the effects. Effects are decoupled from the component and can be reused.

  1. constructor(connect: Connect)

Every component using effects must inject the Connect function since there is no way to automatically instantiate a provider.

  1. connect(this)

This function initializes the effects. It should be called after initial values are set in the constructor.

We can work with one or more effects classes to describe how the state should change, or what side effects should be executed.

@Injectable()
export class MyEffects implements Effect<MyComponent> {
    constructor(private http: HttpClient) {
        console.log("injector works", http)
    }

    @Effect({ markDirty: true })
    count(state: State<MyComponent>) {
        return state.count.pipe(delay(1000), increment(1))
    }

    @Effect()
    countChanged(state: State<MyComponent>, context: MyComponent) {
        return state.count.subscribe(context.countChanged)
    }

    @Effect()
    logViewChild(state: State<MyComponent>) {
        return state.viewChild.changes.subscribe(viewChild => console.log(viewChild))
    }

    @Effect()
    logViewChildren(state: State<MyComponent>) {
        return queryList(state.viewChildren).subscribe(viewChildren => console.log(viewChildren))
    }

    @Effect()
    logEvent(state: State<MyComponent>) {
        return state.event.subscribe(event => console.log(event))
    }
}

Anatomy of an effect

In this implementation, each method decorated by the @Effect() decorator will receive two arguments.

  1. state: State<MyComponent>

The first argument is a map of observable properties corresponding to the component that is being decorated. If the component has own property count: number, then state.count will be of type Observable<number>. Subscribing to this value will immediately emit the current value of the property and every time it changes thereafter. For convenience, the initial value can be skipped by subscribing to state.count.changes instead.

  1. context: Context<MyComponent>

The second argument is the component instance. This value always reflects the current value of the component at the time it is being read. This is very convenient for reading other properties without going through the problem of subscribing to them. It also makes it very easy to connect to @Output().

There are three possible behaviours for each effect depending on its return value:

  1. Return an Observable.
@Effect({ markDirty: true })
count(state: State<MyComponent>) {
    return state.count.pipe(delay(1000), increment(1))
}

When an observable is returned, the intention is to create a stream that updates the value on the component whenever a new value is emitted. Returning an observable to a property that is not an own property on the class should throw an error.

  1. Return a Subscription
@Effect()
logEvent(state: State<MyComponent>) {
    return state.event.subscribe(event => console.log(event))
}

When a subscription is returned, the intention is to execute a side effect. Values returned from the subscription are ignored, and the subscription is cleaned up automatically when the effect is destroyed.

  1. Return void

When nothing is returned, it is assumed that you are performing a one-time side-effect that does not need any cleanup afterwards.

Each effect method is only executed once. Each stream should be crafted so that it can encapsulate all possible values of the property being observed or mutated.

Because each effect class is an injectable service, we have full access to the component injector including special tokens such as ElementRef.

constructor(http: HttpClient) {
    console.log("injector works", http)
}

We can delegate almost all component dependencies to the effects class and have pure reactive state. This mode of development will produce very sparse components that are almost purely declarative.

Lastly, the @Effect() decorator itself can be configured.

interface EffectOptions { 
    markDirty?: boolean
    detectChanges?: boolean
    whenRendered?: boolean
}

The first two options only apply when the effect returns an observable value, and controls how change detection is performed when the value changes. By default no change detection is performed.

The last option is speculative based on new Ivy features. Setting this option to true would defer the execution of the effect until the component is fully initialized. This would be useful when doing manual DOM manipulation.

Do we even need lifecycle hooks?

You might have noticed that there are no lifecycle hooks in this example. Let's analyse what a few of these lifecycle hooks are for and how this solution might absolve the need for them.

  1. OnInit

Purpose: To allow the initial values of inputs passed in to the component and static queries to be processed before doing any logic with them.

Since we can just observe those values when they change, we can discard this hook.

  1. OnChanges

Purpose: To be notified whenever the inputs of a component change.

Since we can just observe those values when they change, we can discard this hook.

  1. AfterContentInit

Purpose: To wait for content children to be initialized before doing any logic with them.

We can observe both @ContentChild() and @ContentChildren() since they are just properties on the component. We can discard this hook.

  1. AfterViewInit

Purpose: To wait for view children to be initialised before doing any logic with them. Additionally, this is the moment at which the component is fully initialised and DOM manipulation becomes safe to do.

We can observe both @ViewChild() and @ViewChildren() since they are just properties on the component. If that's all we are concerned about, we can discard this hook.

For manual DOM manipulation, there is another option. Angular Ivy exposes a private whenRendered API that is executed after the component is mounted to the DOM. This is complimentary to the markDirty and detectChanges API that are also available, but not required for this solution. At this point in time there is no example to demonstrate how this might be used, but it is my opinion that once a reasonable solution is found we can discard this lifecycle hook too.

  1. NgOnDestroy

Purpose: To clean up variables for garbage collection after the component is destroyed and prevent memory leaks.

Since this hook is used a lot to deal with manual subscriptions, you might not need this hook. The good thing is that services also support this hook, do you could move this into the Effect class instead.

Conclusions

Purely reactive components are much simpler constructs. With the power to extract complex logic into reusable functions this would result in components that are much more robust, reusable, simpler to test and easier to follow.

This repository hosts a working implementation of these ideas.

stupidawesome commented 4 years ago

Concrete example building Button and Select components.

stupidawesome commented 4 years ago

Announcement