Unexpected issue during concatLatestFrom migration

jbardon

Jérémy Bardon

Posted on July 12, 2023

Unexpected issue during concatLatestFrom migration

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))
);
Enter fullscreen mode Exit fullscreen mode

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))
);
Enter fullscreen mode Exit fullscreen mode

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)),
);
Enter fullscreen mode Exit fullscreen mode

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)),
);
Enter fullscreen mode Exit fullscreen mode

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$)
);
Enter fullscreen mode Exit fullscreen mode

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);
  })
);
Enter fullscreen mode Exit fullscreen mode

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);
    })
  ))
);
Enter fullscreen mode Exit fullscreen mode

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);
  })
);
Enter fullscreen mode Exit fullscreen mode

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);
  })
);
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
jbardon
Jérémy Bardon

Posted on July 12, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related