What is the difference between the following two observable mappings?
(if something in the following code appears strange to you: it stems from a learning-by-doing hobby project; I still learn RxJS)
I have a component with a getter and a constructor. Both read information from the app's ngrx store and extract a string (name
).
The only difference between the getter and the constructor: the getter is used in the HTML and the observable it returns is sent through an async
pipe, whereas the observable mapping in the constructor is finished by a subscription using subscribe
. I expect both of them to fire as often as a new value for name
becomes available.
But instead only the getter works that way and provides the async
pipe in the HTML where it is used with new values of name (console.log('A')
is called for every name change). The subscribe
subscription's callback is called only once: console.log('B')
and console.log('B!')
are both called exactly once and never again.
How can this difference in behavior be explained?
Snippet from my component:
// getter works exactly as expected:
get name$(): Observable<string> {
console.log('getter called')
return this.store
.select(this.tableName, 'columns')
.do(_ => console.log('DO (A)', _))
.filter(_ => !!_)
.map(_ => _.find(_ => _.name === this.initialName))
.filter(_ => !!_)
.map(_ => {
console.log('A', _.name)
return _.name
})
}
// code in constructor seems to lose the subscription after the subscription's first call:
constructor(
@Inject(TablesStoreInjectionToken) readonly store: Store<TablesState>
) {
setTimeout(() => {
this.store
.select(this.tableName, 'columns')
.do(_ => console.log('DO (B)', _))
.filter(_ => !!_)
.map(_ => _.find(_ => _.name === this.initialName))
.filter(_ => !!_)
.map(_ => {
console.log('B', _.name)
return _.name
})
.subscribe(_ => console.log('B!', _))
})
}
Additional information: If I add ngOnInit
, this life cycle hook is called exactly once during the whole test. If I move the subscription from the constructor to the ngOnInit
life cycle hook, it does not work any better than from within the constructor. Exactly the same (unexpected) behavior. The same applies to ngAfterViewInit
and further life cycle hooks.
Output for the name changes 'some-name' -> 'some-other-name' -> 'some-third-name' -> 'some-fourth-name' -> 'some-fifth-name'
:
[UPDATE] as suggested by Pace in their comment, I added getter call logs
[UPDATE] do
s added as suggested by Pace
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-name
DO (B) (3) [{…}, {…}, {…}]
B some-name
B! some-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-other-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-third-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-fourth-name
getter called
DO (A) (3) [{…}, {…}, {…}]
A some-fifth-name
Example content of the output printed by the console.log
s in the do
s:
[
{
"name": "some-name"
},
{
"name": "some-other-name"
},
{
"name": "some-third-name"
}
]
Seems as if the subscribe
subscription gets lost after its first call. But why?
You should never use a getter like that. Do not return an Observable from a getter.
Angular will unsubscribe/subscribe again and again, everytime a change detection cycle happens (which happens a lot).
For now on I'll write "CD" for "change detection"
Simple demo of that:
Take a really simple component:
// only here to mock a part of the store
const _obsSubject$ = new BehaviorSubject('name 1');
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
get obs$() {
return _obsSubject$
.asObservable()
.pipe(tap(x => console.log('getting a new value')));
}
randomFunction() {
// we don't care about that, it's just
// to trigger CD from the HTML template
}
}
You'll see getting a new value
in your console, and every time you click on the button "Click to trigger change detection", which has a (click)
event registered, it'll trigger a new CD cycle.
And, as many times as you click on that button, you'll see that you are getting twice getting a new value
.
(twice is because we're not in production mode and Angular performs 2 CD cycles to make sure the variable has not changed between the first and the second change detection, which might lead to problems but that's another story).
The point of an observable is that it can remains open for a long time and you should take advantage of that. In order to refactor the previous code to keep the subscription opened and avoid unsubscribing/subscribing again, we can just get rid of the getter and declare a public variable (accessible by the template):
// only here to mock a part of the store
const _obsSubject$ = new BehaviorSubject('name 1');
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
obs$ = _obsSubject$
.asObservable()
.pipe(tap(x => console.log('getting a new value')));
randomFunction() {
// we don't care about that, it's just
// to trigger CD from the HTML template
}
}
And now, no matter how many times you click on the button, you'll see one and only one getting a new value
(until the observable emits a new value of course), but the change detection will not trigger a new subscription.
Here's a live demo on Stackblitz so you can play around and see the console.log
happening =)
https://stackblitz.com/edit/angular-e42ilu
EDIT:
A getter
is a function, and thus, Angular has to call it on every CD to check if there's a new value coming from it that should be updated in the view. This costs a lot, but it's the principle and the "magic" of the framework. That's also why you should avoid running intensive CPU tasks in function that might be triggered on every CD. If it's a pure function (same input same output AND no side effects), use a pipe because they're considered "pure" by default and cache the results. For the same arguments they will run the function in the pipe only once, cache the result and then just return the result instantly without running the function again.
The Observable returned from ngrx.select()
will only fire when the data in the store has changed.
If you want the Observable to fire when initialName
changes, then I would recommend converting initialName into an RXJS Subject
and using combineLatest
:
initialNameSubject = new BehaviorSubject<string>('some-name');
constructor(
@Inject(TablesStoreInjectionToken) readonly store: Store<TablesState>
) {
setTimeout(() => {
this.store
.select(this.tableName, 'columns')
.combineLatest(this.initialNameSubject)
.map(([items, initialName]) => items.find(_ => _.name === initialName))
.filter(_ => !!_)
.map(_ => {
console.log('B', _.name)
return _.name
})
.subscribe(_ => console.log('B!', _))
})
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With