Jérémy Bardon
Posted on July 12, 2023
When using NgRx as a state manager for Angular, you end up using withLatestFrom
RxJS operator. It's often in effects to retrieve the current state from selectors.
NgRx v11 introduced concatLatestFrom
operator as a drop-in replacement for withLatestFrom
. The migration should be seamless but sneaky issues can appear with withLatestFrom
misuses.
Identify withLatestFrom misuses
This RxJS operator shines when you need to retrieve an observable value (aka. selector) within an effect.
// Emits 0, 1, 2 and 3 with a 2s interval
selectCount$ = timer(0, 2_000).pipe(take(4));
effect$ = this.actions$.pipe(
withLatestFrom(selectCount$),
tap(([action, count]) => console.log(action, count))
);
In this example, the selector value is available in the effect. action$
is the main observable while selectCount$
is a "side observable" running separately. Having updates on the selector won't trigger the effect.
As soon as the effect is active, withLatestFrom
side process kicks in watching for given observables changes. It keeps track of the last known value to make it synchronously available by the main observable.
Curious about what "side process" means? Checkout RxJS source code, it does subscribe outside of the main observable scope
Let's look at another example but replace the selector with an HTTP request. The idea is to only trigger the request once and access its value.
// Emulates http request which don't emit immediately
httpRequest$ = of('result').pipe(delay(2_000));
effect$ = this.actions$.pipe(
withLatestFrom(httpRequest$),
tap(([action, result]) => console.log(action, result))
);
The above example works most of the time but can fail at some point. What happens if the effect receives an action before the request ends?
Nothing, the operator acts as a filter and ignores the action. withLatestFrom
can't provide the last value since the request didn't emit any value yet.
Don't use
withLatestFrom
on observables without an initial value. Stick with selectors and be careful with other observables.
This issue is hard to spot since it doesn't happen often. withLatestFrom
watch process starts at effect class instantiation time. Usually, there is enough time for the request to complete before the effect receives an action.
What's the difference with concatLatestFrom?
concatLatestFrom
is an RxJS operator provided by NgRx to address the effect's specific use cases. NgRx v15 adds an
official ESLint rule which encourages migration.
effect$ = this.actions$.pipe(
withLatestFrom(selectItem(action.id)),
);
This snippet doesn't work. The intent is to retrieve an item from the store based on the incoming id.
withLatestFrom
can't help us in this case. The selector starts once at effect class instantiation time. At this moment, the effect didn't receive any action yet thus the item id isn't reachable.
effect$ = this.actions$.pipe(
concatLatestFrom(action => selectItem(action.id)),
);
Did you notice the parameter changed from an observable to an arrow function? It's important to postpone the observable watch process.
The selector won't start at effect class instantiation time. Instead, the effect can start the selector on demand when it receives an action containing an id. It does so by executing the arrow function returning the selector to watch.
This example works fine, it looks safe to apply the new ESLint rule. Let's replace the existing withLatestFrom
with its more flexible counterpart.
httpRequest$ = of('result').pipe(delay(2_000));
effect$ = this.actions$.pipe(
concatLatestFrom(() => httpRequest$)
);
Unfortunately, this small change broke the effect. It seems concatLatestFrom
ignores the action. Let's check this operator source code to figure out what happens.
It leverages withLatestFrom
which makes sense. We observed this operator might ignore actions under some conditions, right?
The request starts once the effect receives an action. At this point, the main observable tries to read the request value synchronously. It fails since the request didn't emit the response yet hence no "latest" value is available.
Fix combineLatestFrom misuses
It's bad idea to use LatestFrom
operators with observables without initial value (such as http requests). It means the observable to watch must emit without delay.
Imagine an effect which needs to retrieve a feature flag from an API before running another request based on the action id.
effect$ = this.actions$.pipe(
concatLatestFrom(() => featureFlagRequest$),
switchMap(([action, flag]) => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
);
Thanks to your experience, you can spot the misuse of concatLatestFrom
watching an HTTP request.
Let's see how we can refactor this piece of code and keep the desired behaviour. The intuitive solution is to move the feature flag request into the switchMap
.
effect$ = this.actions$.pipe(
switchMap(([action, flag]) => featureFlagRequest$.pipe(
switchMap(flag => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
))
);
Having both requests together adds nesting and result in complex RxJS code.
The request also runs on each action and not once slowing down the reactivity. It's good when having an up-to-date value is important but could have caching otherwise.
What about replacing concatLatestFrom
with an observable composition operator?
effect$ = this.actions$.pipe(
switchMap(action => featureFlagRequest$.pipe(
map(flag => [action, flag])
),
switchMap(([action, flag]) => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
);
The feature flag request and the second request are in different code blocks. It looks like concatLatestFrom
approach but switchMap
waits for the request to complete.
The extra map
operator is a downside. It could not scale with many requests. Another way to improve this is to leverage the zip
operator.
effect$ = this.actions$.pipe(
switchMap(action => zip(
of(action),
featureFlagRequest$, // Can safely add more requests here
)),
switchMap(([action, flag]) => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
);
The most scalable approach is quite verbose and complex. Prefer the previous solution for simpler use cases with a single request.
One last solution is to keep withLatestFrom
for such cases while having its downsides in mind. Better avoid having both operators in your codebase to not confuse other developers.
Wrapping up
Using concatLatestForm
as suggested by ESLint rules is a nice idea. It helps when selectors needs to access action details but also ease mocking in unit tests.
Pay attention to the watched observables, it should emit without delay. If not, the operator ignores the action whereas it might work with withLatestFrom
.
Watch-out for withLatestFrom
misusages during the migration and consider using switchMap
instead. Listening selectors is safe but other observables such as HTTP request are not. Check out my Stackblitz playground to perform your tests.
Thanks for reading!
Posted on July 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.