stupidawesome / ng-effects

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

Composition API #7

Closed stupidawesome closed 4 years ago

stupidawesome commented 4 years ago

This PR adds the foundation for a composition API similar to that of Vue. There are a number of fundamental differences which will become apparent but aren't that important.

Basic usage will look something like this.

export const MyConnectable = connectable<CompositionComponent>(state => {
    const http = inject(HttpClient)

    effect(() => { // runs immediately and every time count changes
        return timer(1000).subscribe(() => {
            state.count += 1
        })
    })

    onChanges(() => {
        effect(() => console.log('state changed!'))
    })

    afterViewInit(() => {
        effect(() => console.log('first render!'))
    })

    whenRendered(() => {
        effect(() => console.log('view changed!'))
    })

    onDestroy(() => {
        // cleanup logic
    })
})

@Component({
    providers: [MyConnectable]
})
export class MyComponent extends Connectable {
    count = 0
}

Here we borrow the use of reactive proxies from Vue to drive the scheduling of an effect. We also borrow the behaviour of lifecycle hooks so that effects are reset each time a lifecycle hook fires (with the exception of onDestroy() which is for cleanup only).

Dependency injection works via inject(), which is the same as the inject function exported by Angular, except the interface has been tweaked to accept AbstractType<T>.

connectable(() => {
    const http = inject(HttpClient) // valid

    afterViewInit(() => {
        const http = inject(HttpClient) // not valid here
        effect(() => {
            const http = inject(HttpClient) // not valid here
            setTimeout(() => {
                const http = inject(HttpClient) // not valid here
            })
        })
    })
})

The connectable function returns a connected provider which is instantiated after calling connect(injector) inside the constructor component. This provider can be used with any component or directive, as well as combined with other providers. Only effects provided to the component are executed, they are not inherited from parent injectors.

kaliumxyz commented 4 years ago

I approve of this pull request

stupidawesome commented 4 years ago

Effects can also be composed using the ngOnConnect hook.

Similar to setup from Vue, but called after the component is constructed. It receives a reactive this context so that effects are automatically triggered when values change. When state is updated outside of ngOnConnect, such as when a button triggers incrementCount, its value is propagated to effects on the next change detection run. Template events automatically trigger change detection so no manual change detection is required. A non reactive reference to the component can be injected with useContext().

Example:

@Component()
export class MyComponent extends Connectable {
    count: number = 0

    incrementCount() {
        this.count += 1
    }

    ngOnConnect() {
        const http = inject(HttpClient)

        effect(() => {
            console.log(this.count)

            return timer(1000).subscribe(() => {
                this.incrementCount()
            })
        })
    }
}

Effects can be composed by calling functions inside the ngOnConnect hook, or provided as a Connectable in the providers array.

// Connectable provider
const MyConnectable = connectable<MyComponent>((state) => {
    effect(() => console.log("MyConnectable"))
})

// Composable function
function useConnectable(state: MyComponent) {
    effect(() => console.log("useConnectable"))
}

@Component({
    providers: [MyConnectable]
})
export class MyComponent extends Connectable {
    ngOnConnect() {
        // functional composition
        useConnectable(this)

        effect(() => console.log("ngOnConnect"))
    }
}
stupidawesome commented 4 years ago

After trying several different approaches I've decided that the next minor version of this library will deprecate the use of the decorator API in favour of this composition API. Additionally, components will need to extend a base Connectable class to retain connect functionality. This is a breaking change, but I'll keep the old API around for existing projects that use it until v10 comes out. Projects not using the new API can just tree shake it away and vice versa.

Connected components will look something like this:

@Component({
    selector: "app-connected",
    template: `
        <div>Count: {{ count }}</div>
    `,
    styleUrls: ["./connected.component.css"],
})
export class ConnectedComponent extends Connectable {
    @Input()
    count: number = 0

    @Output()
    countChange = new HostEmitter(true)

    incrementCount() {
        this.count += 1
    }

    ngOnConnect() {
        const elementRef = inject(ElementRef)
        effect(() => {
            this.countChange(this.count)
            return timer(1000).subscribe(() => this.incrementCount())
        })
    }
}

By using class initializer syntax and injecting dependencies within the ngOnConnect hook we can omit the constructor. If the constructor is needed, it will need to pass in the node Injector to the super call:

constructor(injector: Injector) {
    super(injector)
}

We can still provide additional connectable functions to the component if we wish.

const MyConnectable = connectable(state => {
    // etc
})

@Component({
    providers: [MyConnectable]
})
export class ConnectedComponent extends Connectable {
    // etc
}

The Connectable base class uses lifecycle hooks to drive the connect mechanism. Components are now "connected" during ngOnInit, which means inputs and static queries will be resolved before the ngOnConnect hook is fired. In previous versions components would be connected during the constructor call. The base class also implements other lifecycle hooks, so the user needs to be aware that they shouldn't override them carelessly. The user is advised not to mix ngOnConnect with other lifecycle hooks. Connected components are presented as an alternative method of declaring angular components.

stupidawesome commented 4 years ago

Development of this feature continued here