The official NgRx migration is straightforward, but the subtle differences in internal timing can leave effects silently broken.
Spotting the patterns that break
State management with NgRx often leads to withLatestFrom being used inside effects to pull the current state from selectors.
The concatLatestFrom operator arrived in NgRx v11 as a direct stand-in for withLatestFrom. Generally, the swap is painless, yet some edge cases hide in plain sight when withLatestFrom wasn't used correctly.
Start with clear definitions
This operator works well when an effect must access a selector's current snapshot.
// 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 the snippet above, the effect accesses the selector value without issue. The action stream drives the effect, while the selector runs as a separate, independent observable. New values from the selector alone won't trigger the effect.
As soon as an effect becomes active, withLatestFrom starts a background process that subscribes to the given observables. It maintains the most recent emitted value so that the main observable can read it synchronously.
A thorough look at the internals shows that this operator subscribes outside the scope of the main observable, so it is always running.
Now consider swapping the selector for an HTTP request. The goal here is to make the request run once and then access its result.
// 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))
);
This setup works under normal conditions but can fail under load. What happens if the effect receives an action before the request has settled?
Nothing. In that case, the operator silently drops the action because there is no "latest" value yet. The request hasn't emitted anything, so withLatestFrom has no value to provide.
Keep
withLatestFromfor observables with an initial value, like store selectors. Using it on other observables without a default value is risky.
This failure mode is rare, which makes it easy to miss. The subscription starts during effect instantiation, and by the time the first action arrives, the request has usually completed. But that timing isn't a guarantee.
What does concatLatestFrom do differently?
NgRx provides concatLatestFrom specifically to meet needs that come up inside effects. Starting with NgRx v15, there's also an official ESLint rule that encourages moving to the newer operator.
effect$ = this.actions$.pipe(
withLatestFrom(selectItem(action.id)),
);
That snippet won't work. The goal is to fetch an item from the store using the action's id. Since the selector starts at effect instantiation, and at that point no action has arrived, the id is still unknown.
effect$ = this.actions$.pipe(
concatLatestFrom(action => selectItem(action.id)),
);
The change here is small but essential: the second argument is now a function that returns the observable. This defers the subscription until the effect actually needs it.
Instead of subscribing when the class is created, the selector subscription is initiated when the action with the id arrives. The operator executes the function to get the observable it should watch.
The corrected example works fine, so it seems safe to enable the new ESLint rule. Let's replace withLatestFrom with the newer operator.
httpRequest$ = of('result').pipe(delay(2_000));
effect$ = this.actions$.pipe(
concatLatestFrom(() => httpRequest$)
);
That simple change breaks the effect entirely. The action seems to be ignored. To understand why, look at the operator's implementation.
Internally, it calls withLatestFrom, which is a strong hint. As we've seen, this operator can silently filter out actions when no latest value is available.
In this case, the request starts when the effect receives the action. At that same moment, the main observable tries to read the request's value synchronously. It fails because the response hasn't arrived yet; there simply is no "latest" value.
Fixing improper combineLatestFrom usage
Applying LatestFrom operators to observables that lack an initial emission — like HTTP calls — is generally discouraged. The source observable being watched needs to emit immediately for the operator to function correctly.
Consider a scenario where an effect must fetch a feature flag from an API before executing a subsequent request that depends on the action id.
effect$ = this.actions$.pipe(
concatLatestFrom(() => featureFlagRequest$),
switchMap(([action, flag]) => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
);
With prior knowledge, it's easy to identify the incorrect use of concatLatestFrom when it's watching an HTTP request.
Let's explore how to refactor this code while preserving its intended behavior. A straightforward approach is to relocate the feature flag request inside the switchMap.
effect$ = this.actions$.pipe(
switchMap(([action, flag]) => featureFlagRequest$.pipe(
switchMap(flag => {
if (!flag) {
return legacyRequest(action.id);
}
return request(action.id);
})
))
);
Combining both requests within the same block introduces additional nesting, leading to more convoluted RxJS code.
Moreover, this causes the request to fire on every action rather than just once, which can hinder responsiveness. While this is acceptable when fresh data is critical, caching could be beneficial in other cases.
Could an observable composition operator serve as a better alternative to concatLatestFrom?
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 secondary request reside in separate segments. While this resembles the concatLatestFrom pattern, the switchMap ensures the initial request completes before proceeding.
The added map operator is a drawback, which could become problematic with multiple requests. An alternative enhancement involves using 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 extendable solution tends to be wordy and intricate. For simpler scenarios involving a single request, the earlier method is more suitable.
Another option is retaining withLatestFrom for these cases, acknowledging its limitations. However, mixing both operators in a codebase could lead to confusion among other developers.
Conclusion
Adopting concatLatestForm as guided by ESLint rules is a sensible approach. It facilitates access to action properties within selectors and simplifies unit test mocking.
Keep in mind that the observable being watched must emit synchronously. Otherwise, the operator may skip the action, even though withLatestFrom might handle it correctly.
Be cautious about withLatestFrom misapplications during the migration and consider switching to switchMap. While observing selectors is generally safe, other observables like HTTP calls are not. Feel free to experiment in my Stackblitz playground.
