-
-
Notifications
You must be signed in to change notification settings - Fork 2k
RFC: Component: Proposal for a new package component
#2052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Is this going to be extending angular component implementation? Not completely follow. |
Thanks for sharing this RFC, I really like your ideas 😄 |
@Tibing what exactly? Is there a feature that is missing? |
@BioPhoton, I mean I like your idea to make Angular truly reactive. I still didn't get too deep to the RFC, whereas from the first sight, I have the following questions:
@OnChanges$() onChanges$: Observable<SimpleChanges>;
But why do we need it if we have Observable inputs? As I see it properly, Observable inputs have to fire before onChanges$. I think I didn't get something 🙃
As I stated before, I didn't get in your RFC too deep, so, please, don't treat my comments too serious. I'm going to play with the reactive approach in Angular during the weekend and then I'll be able to add more constructive comments. |
This is a really great idea! These things would make it frictionless to work with Observables properly in Angular, it's always a pain to write so much boilerplate when it would be so natural to use observables (@input(), ngOnChanges practically screams Observable value to me). Some previous discussion on the input topic can be found here as well: angular/angular#5689 |
+1, these are great ideas that definitely make reactive programming easier with Angular. Since it's highly related, I also want to give a shout-out to my own library @lithiumjs/angular that implements many of these ideas through new decorators that can be applied to component properties, inputs, outputs, host listeners, lifecycle events, etc. |
A great idea indeed! |
What if you just start with the directives? I feel like multiple packages might be better than a single, large package containing everything. We actually forgo ngOnChanges entirely now - at my workplace we use the following directives to hook up @input() to a BehaviorSubject:
The decorator essentially adds a getter / setter which allow reading / writing to the underlying BehaviorSubject. The above code desugars to:
We'd definitely use something which provided improvements to the templates. However, the decorators for lifecycle hooks might prove to be superfluous. |
I also like the idea of having one decorator per hook.
We may need lifecycle hooks in addition to observable input bindings
The goal here is to get observables from the view. Button clicks, inputs, any dom events, any angular event binding i.e. |
The package contains only stuff for components/directives
Actually all lifacycles hooks are considered under section "Component and Directive Life Cycle Hooks"
@ObservablePropertyAccessor()
_sampleProp$ = new BehaviorSubject<number>(undefined);
@Input()
sampleProp: number; 2 things I can say here: |
I would be interested in feedback on 2 extensions:
Here a review of the suggested features, as well as general feedback, would be nice. :) |
I know that the NgRx team will agree that we should not add additional decorators. Angular uses decorators heavily, but they are removed at compile time, since they are only used to instruct the compiler, create injectors, and so on. Decorators is still a non-standard feature. It might never make it into ECMAScript. Even if it does, syntax and semantics might change. Several of the use cases you suggest should be doable using Ivy features such as this |
@BioPhoton For the push pipe, we should make sure not to trigger/queue multiple change detection cycles from the same change detection cycle. Ivy's Meaning, if two components depend on the same observable and a new value is emitted, only a single change detection cycle should occur right after the value emission as a result of the push pipe. This could be done by calling |
@BioPhoton in the "Output Decorator" section do you mean |
@LayZeeDK This would only be for Ivy applications and it would be using |
A high level constraint I'd like to place on the RFC: we should avoid decorators as much as possible. You can't type check them and they break lazy loading. If we have to use decorators for some of these APIs then we should take a similar approach to the AOT compiler or ngx-template-streams: apply code transforms to de-sugar them and strip the decorators. |
Nice work @BioPhoton! Just had sometime to read through this. I am sure more thoughts will come to me as time goes on and I digest more, but the first thought I am having is that I would love to see a take on the lifecycle hooks that doesn’t require a decorator. For example, a more functional solution like: ‘onInit(()=>{...});’ etc... These could be wired up in the constructor of the component. This could gain inspiration from the new Vue 3.0 proposal. Thoughts? Is it even technically possible? |
@wesleygrimes, as far as I know, it is possible. However, setting up everything al the time in the CTOR is something repetitive that I try to avoid. I guess we should investigate in the suggestion from @MikeRyanDev here @MikeRyanDev regarding the change-detection, It should have a flag to detach CD and trigger it manually (zone-less), but by default, it should work as the async pipe. |
Agreed on the CTOR part, could just be a class field initialized with |
I've come back to this because I wanted the I still feel like there are a lot of wins to be had, just by releasing things piecemeal. Is there an initial proof of concept for the *let directive? If there is, I wouldn't mind being able to see it. |
The let directive is more or less ready. We have to clarity some open questions etc.. The poc is in the component branch. Let's me know what you think about the context infos of the stream (error, complete) |
I think the concept is great. The only thing I don't understand: Why make this part of ngrx? I feel this is something that is not related to ngrx in any way apart from "they both use rxjs heavily". On the other hand I understand that in this repo you will find more feedback/activity/attention. |
Hi @dummdidumm, from the start NgRx has always been about more than just state management. We are a platform for reactive technologies. Ng = Angular + Rx = Reactive. With this new addition we are continuing with that trend to expand our platform even more. We want to provide reactive ways for angular applications to be constructed. |
I created NgObservable as a way to tackle most of these issues within with current compiler constraints. The suggestions I make below however also include hypothetical APIs that would require changes to Angular itself. Input DecoratorInputs should not be turned into observables. It's better to use the interface Props {
title: string
}
@Component()
class Component extends NgObservable implements Props {
@Input()
public title: string
constructor() {
ngOnChanges<Props>(this).pipe(
select((changes) => changes.title)
).subscribe((title) => {
// strongly typed value
console.log(title.currentValue)
})
}
} With changes to the framework it shouldn't be necessary to extend a base class just to provide lifecycle hooks interface Props {
title: string
}
@Component({
features: [OnChanges] // based on hostFeatures in Ivy
})
class Component implements Props {
@Input()
public title: string
constructor() {
ngOnChanges<Props>(this).pipe(
select((changes) => changes.title)
).subscribe((title) => {
// strongly typed value
console.log(title.currentValue)
})
}
} Similar to Output DecoratorNo API change. Same as OP. HostListener DecoratorUsing an RxJS subject that can also be called like a function, it is possible to adapt some of the existing Angular APIs to turn them into observable streams without changing any of the framework code. // Invoke subject signature
export interface InvokeSubject<T> extends Subject<T> {
(next: T): void
(...next: T extends Array<infer U> ? T : never[]): void
}
class Component {
@HostListener("click", ["$event"])
public listener = new InvokeSubject<Event>
constructor() {
this.listener.subscribe((event) => {
console.log(event)
})
}
} If changing the framework is feasible, this could be implemented as a hook too in a way that mirrors the class Component {
constructor() {
hostListener(this, "click", ["$event"], { useCapture: true })subscribe((event) => {
console.log(event)
})
}
} HostBinding DecoratorNo API Change. Host bindings are just part of the component snapshot which are updated on change detection runs anyway, so there's no need to make this an observable. Input BindingTo perform change detection automatically, we need to know when the state of the component changes. The best way to do this is with a dedicated "State" subject. This subject would be to components what Router is to the Angular routes (we then treat the component instance as a "stateSnapshot"). Currently there's a bit of ceremony needed to achieve this using the library I developed. @Component({
providers: [StateFactory, Stream]
})
class Component extends NgObservable {
@Input()
title: string // the current or "snapshot" value
constructor(@Self() stateFactory: StateFactory, @Self() stream: Stream) {
const state = stateFactory.create(this)
// imperative API
// queues change detection to run next application tick()
// works like React setState basically
state.next({ title: "Angular" })
// reactive API
// automatically cleans up subscription when component destroyed
stream(state)(ngOnChanges(this).pipe(
select((changes) => changes.title),
map((title) => ({ title }))
))
// bonus: observe changes to entire component
state.subscribe(snapshot => console.log(snapshot))
}
} With Angular Ivy and other framework changes this could be simplified. @Component({
features: [NgOnChanges]
})
class Component {
@Input()
title: string // the current or "snapshot" value
constructor(stateFactory: StateFactory) {
const state = stateFactory.create(this)
state.next({ title: "Angular" })
stream(state)(ngOnChanges(this).pipe(
select((changes) => changes.title),
map((title) => ({ title }))
))
state.subscribe(snapshot => console.log(snapshot))
}
} Template BindingsRemove all async logic from the template and everything becomes much simpler. Treat the component instance as a snapshot of the current state, then set good defaults and handle undefined behaviour with There are {{totalActiveUsers}} online now.
<ng-container *ngIf="user">
Name: {{user.firstName}}
Surname: {{user.lastName}}
</ng-container>
Hi my name is {{user?.firstName}} {{user?.lastName}} interface User {
firstName: string
lastName: string
}
@Component()
class Component {
user: User
totalActiveUsers: number
constructor(userSvc: UserService, stateFactory: StateFactory) {
const state = stateFactory.create(this)
this.totalActiveUsers = 0
this.user = null
stream(state)({ user: userSvc.getCurrentUser(), totalActiveUsers: userSvc.getActiveUsersCount() })
}
} How this works is that whenever a new value is streamed to the state subject, Output BindingThe same technique mentioned in HostListener can be used here as well. @Component({
template: `
<app-child (stateChange)="onStateChange($event)"></app-child>
`
})
class Component {
onStateChange = new InvokeSubject<StateChange>
constructor() {
this.onStateChange.subscribe((stateChange) => console.log(stateChange))
}
} Component and Directive Life Cycle HooksThese are currently implemented in In Ivy and beyond, I think having feature flags is the best way to express our intent to use lifecycle hooks without explicitly putting them on the component class. This would require changes from See Input Decorator for what I mean. Service Life Cycle HooksServices created at the module level could just inject a provider that registers a single call to ngOnDestroy or ngModuleRef.onDestroy(). Services created at the component level could benefit from Suggested Extensions under @ngRx/component Package
I think that more work should be done on Angular's side to enable the desired behaviour in a way that's performant and ergonomic without breaking or mutating existing APIs. The NgObservable library was the best I could do given Angular's current compiler limitations, I hope this gives you some ideas. |
Hi @stupidawesome! Thanks, soo much for your feedback! Input Decorator vs ngChanges:
In the section "selectChanges RxJS Operator" it is mentioned that this would be similar to input.
The above document is here to speed up an old discussion and I hope to get some feedback from @robwormald in the next week(s). Output Binding If I understand it correctly this is also smaller and way easier to implement than (ngx-template-streams)[https://github.com/typebytes/ngx-template-streams]. If true, I would go with this instead of the approach from @typebytes.
I think so too @stupidawesome! Service Life Cycle Hooks and State Subject
This service helps to manage the component internal state (local state). It does implement:
We decided against pulling in this topic at the current state because it would just bring up too many discussions. Therefore I skipped all the information related to it here. |
I'm a little late to the party, but how about an API like this: https://github.com/dolanmiu/sewers/blob/master/src/app/card/card.component.ts#L5-L8 Essentially it's a type safe decorator to handle all your Observables, called a "Sink" @Sink<CardComponent>({
obs: 'obs$',
data: 'data$'
})
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.scss'],
})
export class CardComponent {
@Input() public obs$: Observable<string>;
public readonly data$: Observable<string>;
public data: string;
public obs: string;
constructor() {
this.data$ = of('hello');
}
} So in the above example, it will auto handle the observable Source: https://github.com/dolanmiu/sewers/blob/master/projects/sewers/src/lib/sink.decorator.ts Based on this talk by @MikeRyanDev: |
While I like that the decorator-approach is more natural for mixins, I feel that the concrete implementation is a little too verbose and one doesn't know whats going on if he does not know the conventions. I think, with how Angular internally works (needing the LifeCycleHooks as methods on the class, not able to add them later on or tell the compiler to call it because we know it's there), the best approach is inheritance. In order to have reusability, one can also use inheritance with mixins. Based on the talk by @MikeRyanDev and using mixins: import {
Component,
OnInit,
OnDestroy,
ɵmarkDirty as markDirty
} from '@angular/core';
import { Subject, Observable, from, ReplaySubject, concat } from 'rxjs';
import { scan, startWith, mergeMap, tap, takeUntil } from 'rxjs/operators';
type ObservableDictionary<T> = {
[P in keyof T]: Observable<T[P]>;
};
type Constructor<T = {}> = new (...args: any[]) => T;
const OnInitSubject = Symbol('OnInitSubject');
export function WithOnInit$<TBase extends Constructor>(Base: TBase) {
return class extends Base implements OnInit {
private [OnInitSubject] = new ReplaySubject<true>(1);
onInit$ = this[OnInitSubject].asObservable();
ngOnInit() {
this[OnInitSubject].next(true);
this[OnInitSubject].complete();
}
};
}
const OnDestroySubject = Symbol('OnDestroySubject');
export function WithOnDestroy$<TBase extends Constructor>(Base: TBase) {
return class extends Base implements OnDestroy {
private [OnDestroySubject] = new ReplaySubject<true>(1);
onDestroy$ = this[OnDestroySubject].asObservable();
ngOnDestroy() {
this[OnDestroySubject].next(true);
this[OnDestroySubject].complete();
}
};
}
export function WithConnect<
TBase extends Constructor &
ReturnType<typeof WithOnDestroy$> &
ReturnType<typeof WithOnInit$>
>(Base: TBase) {
return class extends Base {
connect<T>(sources: ObservableDictionary<T>): T {
const sink = {} as T;
const sourceKeys = Object.keys(sources) as (keyof T)[];
const updateSink$ = from(sourceKeys).pipe(
mergeMap(sourceKey => {
const source$ = sources[sourceKey];
return source$.pipe(
tap((sinkValue: any) => {
sink[sourceKey] = sinkValue;
})
);
})
);
concat(this.onInit$, updateSink$)
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => markDirty(this));
return sink;
}
};
}
export class Base {}
const ReactiveComponent = WithConnect(WithOnDestroy$(WithOnInit$(Base)));
@Component({
selector: 'app-root',
template: `
<div class="count">{{ state.count }}</div>
<div class="countLabel">Count</div>
<button class="decrement" (click)="values$.next(-1)">
<i class="material-icons">
remove
</i>
</button>
<button class="increment" (click)="values$.next(+1)">
<i class="material-icons">
add
</i>
</button>
`
})
export class AppComponent extends ReactiveComponent {
values$ = new Subject<number>();
state = this.connect({
count: this.values$.pipe(
startWith(0),
scan((count, next) => count + next, 0)
)
});
pushValue(value: number) {
this.values$.next(value);
}
} This gives us the ability to define convenience-classes like ReactiveComponent while giving the user the ability to mixin other functionality as needed, mitigating the multiple inheritance problem. So he could do stuff like this: export class SomeOtherBaseClass {
// ...
}
const MyCustomBaseClass = WithOnChanges$(WithConnect(WithOnDestroy$(WithOnInit$(SomeOtherBaseClass))))
@Component({...})
export class MyComponent extends MyCustomBaseClass {
// ...
} |
@BioPhoton thanks for the tip about the ReplaySubject. I'll certainly look into doing that. |
Hello :) I took a little peek into the angular compiler - components are core to angular functionality, it would take significant effort to create an alternative component. The current compiled data structure does not support bindings as inputs. Information available about directives after compilation
Inputs are expressed as key value pairs - the key represents the property name on the component and the value represents the property in the template. Angular will have no way to distinguish between an input observable and a regular one. They cannot change how regular inputs work either for backwards compatibility. A possible solution with realistic timelines
Why?
Who? Hope this helps - may you be happy. |
Hi @OlaviSau. Thanks for the answer!
@robwormald is handling this. He is also in the ngrx core team. |
Here the design doc for the |
I'm experimenting with a combination of base class + service injection + proxies. Stackblitz example is here: https://stackblitz.com/edit/angular-9-0-0-rc-1-1qxbvs All reactive state logic is handled by an effects class. The two parts to note here are the Reactive base class, and the effects provider. @Component({
selector: 'app-child',
templateUrl: './child.component.html',
styleUrls: ['./child.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [effects(ChildEffects)]
})
export class ChildComponent extends Reactive {
@Input()
public name: string
@Input()
public age: number
@Output()
public ageChange = new EventEmitter()
public clicked: MouseEvent
} In the effects class, each property marked by the Each effect method is a factory function that receives two arguments: a state object that maps each component property to an observable, and the component instance itself. Each method should return an observable of the same type as the corresponding state object (or a subscription if its a side effect). These methods are invoked during ngAfterViewInit to allow the component to be fully initialised. Change detection can be controlled per property by setting @Injectable()
export class ChildEffects implements Effects<ChildComponent> {
constructor(private http: HttpClient) {}
@Effect({ markDirty: false })
public name(state: State<ChildComponent>, component: ChildComponent) {
/** this.http.get("someUrl") // could do something with http here **/
return of("Stupidawesome")
}
@Effect({ markDirty: true })
public age(state: State<ChildComponent>, component: ChildComponent) {
return interval(1000).pipe(
withLatestFrom(state.age),
map(([_, age]) => age + 1),
tap(age => component.ageChange.emit(age)) // could be streamlined
)
}
// side effect
@Effect()
public sideEffect(state) {
return state.age
.subscribe(age => console.log(`age changed: ${age}`))
}
// Template events
// <p (click)="clicked = $event"></p>
@Effect()
public clicked(state, component) {
return state.clicked
.subscribe(event => console.log(`click:`, event))
}
} The |
Here the design doc for the |
Here a collection of terms used in the document and their explanation: Please let me know if I miss something! |
Looks good @BioPhoton btw. not sure if it's relevant but I feel having inputs as observables natively would be of help for the overall solution. I recently found this nifty library https://github.com/Futhark/ngx-observable-input |
Why this? Just pass an observable to the input property. Even change detection works by default when new values are emitted through input observables. |
Because you might be passing data down from a dumb component to a component that wants to utilise reactivity. |
You decide the data binding API, not your parent components 👴 |
Here's take 2 on my last post with comments on how it deals with the various features desired from a reactive component. For brevity I will only refer to components here, but this pattern could also be applied to directives and modules (anywhere you can add providers). SummaryThe main goal of this implementation is to develop a reactive API for Angular components with the following characteristics:
OverviewThe API takes inspiration from NgRx Effects and NGXS. This example demonstrates a component utilising various angular features that we would like to make observable:
@Component({
selector: "my-component",
template: `
<div (click)="event = $event" #viewChildRef>Test</div>
`,
providers: withEffects(MyEffects),
host: { "(mouseover)": "event = $event" }
})
@RunEffects()
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(@UseEffects() effects: Effects) {
this.count = 0
this.countChange = new EventEmitter()
this.viewChild = null
this.viewChildren = null
this.event = null
}
} Binding the effects class is a three step process.
One or more classes are provided to the component that will provide the effects. Effects are decoupled from the component and can be reused.
Every component using effects must inject the
This decorator does two things. First it decorates all own properties of the component with getter/setter hooks so that changes can be observed. It then calls the 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 effectIn this implementation, each method decorated by the
The first argument is a map of observable properties corresponding to the component that is being decorated. If the component has own property
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 There are three possible behaviours for each effect depending on its return value:
@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.
@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.
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 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 interface EffectOptions {
markDirty?: boolean
detectChanges?: boolean // not implemented yet
whenRendered?: boolean // not implemented yet
} 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 initialised. This would be useful when doing manual DOM manipation. 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.
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.
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.
Purpose: To wait for content children to be initialised before doing any logic with them. We can observe both
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 For manual DOM manipulation, there is another option. Angular Ivy exposes a private
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. ConclusionsIn the purely reactive world, components become 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. I am working on the implementation here: https://github.com/stupidawesome/ng-effects |
Please, no more decorators 🙁 |
Imho reactive hooks is definitely a component feature in the future. |
interface MyState {
count: number
}
class MyEffects implements Effects<MyComponent> {
count = createEffect(
(state: State<MyState>, ctx: MyComponent) => interval(1000)),
{ markDirty: true }
)
}
@Component({
providers: [effects([MyEffects])]
})
class MyComponent implements MyState {
count: number
constructor(connect: Connect) {
this.count= 0
connect(this) // init any "connectable" services
}
} How about this? Example code here |
First pass at implementing a It is invoked once immediately after the first round of change detection finishes. @Effect({ whenRendered: true })
public viewChildren(state: State<TestState>) {
// is executed after view children have been attached
return state.viewChildren.subscribe(value => console.log("viewChildren available:", value))
} |
This comment has been minimized.
This comment has been minimized.
Misko's Ivy design docs are interesting Looks like zones are going away eventually. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
I try to get an opinion on passing the context. What are the pros and cons for you? |
Local effects should be able to compose or connect Observables/EventEmitters in the component instance such as Overall however this is just a convenience. You could also obtain the reference by Passing in the context is something that is common practice using providers. @Component({
providers: [{ provide: Parent, useExisting: ParentComponent }]
})
export class ParentComponent {}
@Component()
export class ChildDirective {
constructor(parent: Parent) {}
} So there is nothing particularly novel about passing in the context. The library currently provides this in two ways: 1. as an argument to each effect method and 2. via the I haven't used or tested this library extensively enough to understand all of the downsides, but the biggest con is probably going to be the use of property interception to construct the The downsides are that it won't be easy to extend a "connected" class. If we only connect "final" classes this can be mitigated (explore the prototype for effects higher up the chain?). The A side effect of using The plus side is that this lets us easily observe all property changes on the component regardless of where those changes originate (ie. template events, Additionally, we get fine tune control over when to trigger change detection. We can make change detection occur when some or all properties change, or we can use sampling/throttling to limit or disable change detection for a period of time. We can also ditch zones. |
This comment has been minimized.
This comment has been minimized.
@stupidawesome thank you for putting these ideas into a library, but we're getting off-topic here with the updates. If you would like to discuss that library further, open an issue in your repo so people can continue discussing it there. |
My 2 cents as a new programmer (just over a year now) for a small business as a one person team. One of the reasons I’ve fell in love with the ngrx modules has been that the code writers are not afraid to address complex ideas to newcomers through ng-conf talks, tonnes of examples and are willing to be explicit about what is good code and what is misuse and bad form. The time to learn is a much under appreciated measure shortened by an awesome and supportive community. So thank you. ❤️ Please keep sharing as you go and keep ability to test at the forefront. I write a lot of boilerplate code to get a component to be reactive and sometimes leave it off in favour of using the angular hooks for smaller use cases which often feels clunky. Especially if I opt for the OnChanges hook which does feels ugly guarding with if statements to get the input I care about (obviously could use get and set but this adds a huge number of lines early in a component when all I want is to be able to immediately see the main component methods at a glance e.g. |
I've implemented https://github.com/Rush/ng-connect-state/ import { ConnectState, connectState } from 'ng-connect-state';
import { interval } from 'rxjs';
@ConnectState()
@Component({ template: `
{{ state.timer }}
<button (click)="state.reload()">Reload</button>
Loading: {{ state.loading.timer }}
`
})
export class InboxComponent {
ngOnDestroy() { }
// this exposes state.timer as a synchronous value state.timer and automatically unsubscribes on destroy
// + it has a .loading indictor, exposes optional observable access as well as can resubscribe to observables on demand
state = connectState(this, {
timer: interval(1000),
})
} |
Uh oh!
There was an error while loading. Please reload this page.
RFC: Component: Proposal for a new package
component
Reactive primitives for components
#2046
component
Table of Content
Summary
This RFC proposes a nice reactive integration of components/directives in Angular.
It's main goal is to provide a set of primitives that serve as the glue between custom reactive code and the framework.
Motivation
Parts of Angular like the
ReactiveFormsModule
,RouterModule
,HttpClientModule
etc. are already reactive.And especially when composing them together we see the benefit of observables. i.e. http composed with router params.
For those who prefer imperative code, it's little effort to restrict it to a single subscription.
On the other hand for those who prefer reactive code, it's not that easy.
A lot of conveniences is missing, and beside the
async
pipe there is pretty much nothing there to take away the manual mapping to observables.Furthermore, an increasing number of packages start to be fully observable based.
A very popular and widely used example is ngRx/store. It enables us to maintain global push-based state management based on observables.
Also, other well-known libraries, angular material provide a reactive way of usage.
This creates even more interest and for so-called
reactive primitives
for the Angular framework, like theasync
and other template syntax, decorators and services.The first step would be to give an overview of the needs and a suggested a set of extensions to make it more convenient to work in a reactive architecture.
In the second step, We will show the best usage and common problems in a fully reactive architecture.
This proposal
give an overview of the needs
suggests a set of extensions to make it more convenient to work reactive with angular components
Overview
As the main requirement for a reactive architecture in current component-oriented
frameworks are handling properties and events of components as well as several specifics for
rendering and composition of observables.
In angular, we have an equivalent to properties and events, input and output bindings_.
But we also have several other options available to interact with components.
The goal is to list all features in angular that need a better integration.
We cover an imperative as well as a reactive approach for each option.
We consider the following decorators:
And consider the following bindings:
Input Decorator
Inside of a component or directive we can connect properties with the components in it bindings over the
@Input()
decorator.This enables us to access the values of the incoming in the component.
Receive property values over
@Input('state')
Imperative approach:
Reactive approach:
Here we have to consider to cache the latest value from state-input binding.
As changes fires before AfterViewInit, we normally would lose the first value sent. Using some caching mechanism prevents this.
Furthermore and most importantly this makes it independent from the lifecycle hooks.
Needs:
Some decorator that automates the boilerplate of settings up the subject and connection it with the property.
Here
ReplaySubject
is critical because of the life cycle hooks.@Input
is fired first onOnChange
where the first moment where the view is ready would beAfterViewInit
Output Decorator
Send event over
eventEmitter.emit(42)
Inside of a component or directive, we can connect events with the components output bindings over the
@Output()
decorator.This enables us to emit values to its parent component.
Imperative approach:
Reactive approach:
Here we change 2 things.
We use a
Subject
to retrieve the button click event andprovide an observable instead of an EventEmitter for @output().
Needs:
No need for an extension.
HostListener Decorator
Receive event from the host over
@HostListener('click', ['$event'])
Inside of a component or directive, we can connect host events with a component method over the
@HostListener()
decorator.This enables us to retrieve the host's events.
Imperative approach:
Reactive approach:
Needs:
We would need a decorator automates the boilerplate of the
Subject
creation and connect it with the property.As
subscriptions
can occur earlier than theHost
could send a value we speak about "early subscribers".This problem can be solved as the subject is created in with instance construction.
HostBinding Decorator
Receive property changes from the host over
@HostBinding('class')
Inside of a component or directive, we can connect the DOM attribute as from the host with the component property.
Angular automatically updates the host element over change detection.
In this way, we can retrieve the host's properties changes.
Imperative approach:
Reactive approach:
TBD
Needs:
Provide an observable instead of a function.
Here again, we would need a decorator that automates the
Subject
creation and connection.As subscriptions can occur earlier than the
Host
could be ready we speak about "early subscribers".This problem can be solved as the subject is created in with instance construction.
Input Binding
Send value changes to child compoent input
[state]="state"
In the parent component, we can connect component properties to the child
component inputs over specific template syntax, the square brackets
[state]
.Angular automatically updates the child component over change detection.
In this way, we can send component properties changes.
Imperative approach:
Reactive approach:
Important to say is that with this case we can ignore the life cycle hooks as the subscription happens always right in time.
We cal rely on trust that subscription to
state$
happens afterAfterViewInit
.Needs:
As we know exactly when changes happen we can trigger change detection manually. Knowing the advantages of subscriptions over the template and lifecycle hooks the solution should be similar to
async
pipe.Already existing similar packages:
Template Bindings
In the following, we try to explore the different needs when working with observables in the view.
Lets examen different situations when binding observables to the view and see how the template syntax that Angular already provides solves this. Let's start with a simple example.
Multiple usages of
async
pipeHere we have to use the
async
pipe twice. This leads to a polluted template and introduces another problem with subscriptions.As observables are mostly unicasted we would receive 2 different values, one for each subscription.
This pushes more complexity into the component code because we have to make sure the observable is multicasted.
Binding over the
as
syntaxTo avoid such scenarios we could use the
as
syntax to bind the observableto a variable and use this variable multiple times instead of using the
async
pipe multiple times.Binding over the
let
syntaxAnother way to avoid multiple usages of the
async
pipe is thelet
syntax to bind the observable to a variable.Both ways misuse the
*ngIf
directive to introduce a context variable and not to display or hide a part of the template.This comes with several downsides:
*ngIf
directiveThe
*ngIf
directive is triggered be falsy values, but we don't want to conditionally show or hiding content,but just introduce a context variable. This could lead to problems in several situations.
async
pipe*ngIf
directive triggered by falsy valuesAs we can see, in this example the
ng-container
would only be visible if the value is1
and thereforetruthy
.All
falsy
values like0
would be hidden. This is a problem in some situations.We could try to use
*ngFor
to avoid this.Context variable over the
*ngFor
directiveBy using
*ngFor
to create a context variable we avoid the problem with*ngIf
andfalsy
values.But we still misuse a directive. Additionally
*ngFor
is less performant than*ngIf
.Nested
ng-container
problemHere we nest
ng-container
which is a useless template code.A solution could be to compose an object out of the individual observables.
This can be done in the view or the component.
Composing Object in the View
Here we can use
*ngIf
again because and object is alwaystruthy
. However, the downside here iswe have to use the
async
pipe for each observable. `Furthermore we have less control over the single observables.A better way would be to move the composition into the template and only export final compositions to the template.
Composition in the Component
As we see in this example in the component we have full control over the composition.
Needs:
We need a directive that just defines a context variable without any interaction of the actual dom structure.
The syntax should be simple and short like the
as
syntax. It should take over basic performance optimizations.Also, the consistent handling of null and undefined should be handled.
Already existing similar packages:
Output Binding
Receive events from child component over
(stateChange)="fn($event)"
In the parent component, we can receive events from child components over specific template syntax, the round brackets
(stateChange)
.Angular automatically updates fires the provides function over change detection.
In this way, we can receive component events.
Imperative approach:
Reactive approach:
Needs:
As it is minimal overhead we can stick with creating a
Subject
on our own.Component and Directive Life Cycle Hooks
As the component's logic can partially rely on the components life cycle hooks we also need to consider the in-out evaluation.
Angular fires a variety of lifecycle hooks. Some of them a single time some of them only once a components lifetime.
Angulars life cycle hooks are listed ere in order:
(Here the Interface name is used. The implemented method starts with the prefix 'ng')
The goal here is to find a unified way to have single shot, as well as ongoing life cycle hooks, and observable.
Imperative approach:
Reactive approach:
As above mentioned in section Input Decorator we replay the latest value to avoid timing issues related to life cycle hooks.
Handle general things for hooks:
Following things need to be done for every lifecycle hook:
Handle hook specific stuff:
To handle the differences in lifecycle hooks we follow the following rules:
Needs
We need a decorator to automates the boilerplate of the
Subject
creation and connect it with the property away.Also
subscriptions
can occur earlier than theHost
could send a value we speak about "early subscribers".This problem can be solved as the subject is created in with instance construction.
Service Life Cycle Hooks
In general, services are global or even when lazy-loaded the are not unregistered at some point in time.
The only exception is Services in the
Components
providers
Their parts of the services logic could rely on the life of the service, which is exactly the lifetime of the component.
Angular for such scenarios angular provides the
OnDestroy
life cycle hook for classes decorated with@Injectable
.The goal here is to find a unified way to have the services
OnDestroy
life cycle hooks as observable.Imperative approach:
Reactive approach:
Needs
We need a decorator to automates the boilerplate of the
Subject
creation and connect it with the property away.Suggested Extensions under @ngRx/component Package
We propose adding an additional package to ngRx to support a better reactive experience in components.
We will manage releases of these packages in three phases:
@Input()
Based on the above listing and their needs we suggest a set of Angular extensions that should make it easier to set up a fully reactive architecture.
Extensions suggested:
Push Pipe
An angular pipe similar to the
async
pipe but triggersdetectChanges
instead ofmarkForCheck
.This is required to run zone-less. We render on every pushed message.
(currently, there is an isssue with the
ChangeDetectorRef
in ivy so we have to wait for the fix.The pipe should work as template binding
{{thing$ | push}}
as well as input binding
[color]="thing$ | push"
and trigger the changes of the host component.Included Features:
AnimationFrameScheduler
(on by default)Let Structural Directive
The
*let
directive serves a convenient way of binding multiple observables in the same view context.It also helps with several default processing under the hood.
The current way of handling subscriptions in the view looks like that:
The
*let
directive take over several things and makes it more convenient and save to work with streams in the template*let="{o: o$, t: t$} as s;"
Included Features:
*ngIf="{}"
normally effects it)async
pipeAnimationFrameScheduler
(on by default)Observable Life Cycle Hooks
A thing which turns a lifecycle method into an observable and assigns it to the related property.
The thing should work as a proxy for all life cycle hooks
as well as forward passed values i.e.
changes
coming from theOnChanges
hook.Included Features
selectChanges RxJS Operator
An operators
selectChanges
to select one or many specific slices fromSimpleChange
.This operator can be used in combination with
onChanges$
.It also provides a very early option to control the forwarded values.
Example of selectSlice operator
Following things are done under the hood:
currentValue
fromSimpleChanges
objectObservable Input Bindings
A property decorator which turns component or directive input binding into an observable and assigned it to the related property.
Following things are done under the hood:
Observable Output Bindings
A property decorator which turns a view event into an observable and assigns it to the related property.
The solution should work do most of his work in the component itself.
Only a small piece in the template should be needed to link the view with the component property.
Following things are done under the hood:
Here a link to a similar already existing ideas from @elmd_:
https://www.npmjs.com/package/@typebytes/ngx-template-streams
How We Teach This
The most important message we need to teach developers is the basic usage of the new primitives.
The rest is up to pure
RxJS
knowledge.Drawbacks
This new library carries:
Alternatives
The text was updated successfully, but these errors were encountered: