In NgRx 22, why use concatLatestFrom from @ngrx/operators instead of withLatestFrom when a price-alert effect needs the traveller's currency from the store?
answer
- eager versus lazy subscription
- a factory called per action
- the action is the factory's argument
- state not registered yet
- moved packages in NgRx 18
basics
~20 sconcatLatestFrom calls its factory only when a matching action arrives, passing that action, so the selector is not subscribed at startup and can depend on the action's data. It has lived in @ngrx/operators since NgRx 18.
solid answer
~40 s`withLatestFrom(store.select(selectCurrency))` subscribes to the selector as soon as the effect starts, so the selector is evaluated against every new state from startup even if no price alert is ever checked, and it can hit a lazily registered feature slice that does not exist yet. It also cannot see the action. `concatLatestFrom((action) => store.select(...))` from `@ngrx/operators` is the lazy version: for each action that passes `ofType`, it calls the factory with that action, reads the latest value and emits the tuple `[action, currency]`, so you can also build a selector from `action.flightId`. Because effects run after the reducers, that value already reflects the triggering action. It moved out of `@ngrx/effects` in NgRx 18, and the `@ngrx/prefer-concat-latest-from` ESLint rule rewrites the eager form.
code
ts · 24 linesimport { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { concatLatestFrom } from '@ngrx/operators';
import { Store } from '@ngrx/store';
import { catchError, map, mergeMap, of } from 'rxjs';
import { PriceApi } from './price-api.service';
import { PriceAlertActions } from './price-alert.actions';
import { selectCurrency } from './traveller.selectors';
export const checkPrice = createEffect(
(actions$ = inject(Actions), store = inject(Store), priceApi = inject(PriceApi)) => {
return actions$.pipe(
ofType(PriceAlertActions.checkRequested),
concatLatestFrom(() => store.select(selectCurrency)),
mergeMap(([{ flightId }, currency]) =>
priceApi.quote(flightId, currency).pipe(
map((quote) => PriceAlertActions.quoteReceived({ flightId, quote })),
catchError(() => of(PriceAlertActions.quoteFailed({ flightId })))
)
)
);
},
{ functional: true }
);go deeper
Recall that concatLatestFrom from @ngrx/operators gives an effect the action plus the latest store value as a tuple.
Explain eager versus lazy: withLatestFrom subscribes at startup, concatLatestFrom calls its factory per action and passes the action in.
Point to the production failure the lazy form prevents, a root effect selecting a lazily registered slice at startup, and the lint rule that enforces the lazy form.
Treat store reads inside effects as a smell to watch: prefer actions that carry what the effect needs, and use concatLatestFrom when state genuinely belongs to the decision.
## The problem: an effect that needs state An **effect** in NgRx usually needs only the data carried by its action. Sometimes it also needs **state**: the flight-search app's price-alert effect receives `PriceAlertActions.checkRequested({ flightId })`, but the price API also wants the traveller's preferred currency, which lives in the store behind the `selectCurrency` selector. Two operators can join the action with the latest store value: - **`withLatestFrom`** from RxJS. - **`concatLatestFrom`** from `@ngrx/operators`. Both emit a tuple of the action and the latest selected value. They differ in *when* they subscribe to the store and *what* they can see. ## withLatestFrom is eager ```ts ofType(PriceAlertActions.checkRequested), withLatestFrom(store.select(selectCurrency)), ``` - The argument is evaluated once, when the effect is created, and `withLatestFrom` subscribes to it as soon as the effect is subscribed, which for a root effect means app startup. - From then on the selector runs on every state change, whether or not a price check is ever requested. - If the selector reads a **feature slice registered lazily** with `provideState` on a route, the slice may not exist yet when the effect starts. The NgRx lint documentation warns that an uninitialized slice can produce an error you are not expecting. - The selector cannot depend on the action, because it is built before any action exists. ## concatLatestFrom is lazy ```ts ofType(PriceAlertActions.checkRequested), concatLatestFrom(() => store.select(selectCurrency)), ``` `concatLatestFrom(factory)` is implemented as `concatMap` over each incoming value: for every action it calls `factory(action)`, subscribes to what the factory returns, takes the latest value with `withLatestFrom`, emits `[action, value]` and unsubscribes. That gives three properties: 1. **Lazy**: the store is only touched when a matching action arrives. 2. **Action-aware**: the factory receives the action, so `concatLatestFrom((action) => store.select(selectAlertForFlight(action.flightId)))` works. 3. **Ordered**: `concatMap` handles one action at a time, in order. Return an array from the factory to read several values: `concatLatestFrom(() => [store.select(selectCurrency), store.select(selectHomeAirport)])` emits `[action, currency, airport]`. ## What value it sees The `Actions` stream emits an action **after** the reducers have processed it, so the state read by `concatLatestFrom` already includes that action's changes. It reads the **current** value, not the next one: `store.select` emits the current state immediately on subscription, so the tuple is produced synchronously. The value is a snapshot; later currency changes do not re-run the effect for an action already handled. | | `withLatestFrom(obs$)` | `concatLatestFrom(() => obs$)` | |---|---|---| | Subscribes to the store | When the effect starts | When a matching action arrives | | Can use the action | No | Yes, factory argument | | Lazy feature slice not loaded yet | Selector may run against it | Not touched until needed | | Package | `rxjs` | `@ngrx/operators` | ## Version and tooling - `concatLatestFrom` was introduced in `@ngrx/effects` in NgRx 11, deprecated there in 17.2 and **removed from `@ngrx/effects` in NgRx 18**. In NgRx 22 the import is `import { concatLatestFrom } from '@ngrx/operators';`, a package that also holds `mapResponse` and `tapResponse`. - The `@ngrx/prefer-concat-latest-from` ESLint rule reports `withLatestFrom` in effects and is auto-fixable. - Code copied from older tutorials that imports it from `@ngrx/effects` no longer compiles on current majors. ## When the effect should not read state at all Reading the store inside an effect is sometimes the right call and sometimes a sign the action is missing data: - If the component already has the currency when it dispatches, putting `currency` in the action's props makes the effect self-contained and easier to reason about. - If the value is global context the dispatcher should not have to know, such as the traveller's preferences or the active search filters, `concatLatestFrom` keeps the component simple. - Either way, the effect should read the value once per action, which is exactly what the lazy operator does. ## Where it belongs in the pipe Place `concatLatestFrom` after `ofType` and before the flattening operator that calls the API, then destructure the tuple: `mergeMap(([{ flightId }, currency]) => priceApi.quote(flightId, currency).pipe(...))`. Keep the request's `map` and `catchError` inside that operator as usual.
- How do you select the alert for the flight named in the NgRx action with concatLatestFrom?Use the factory's argument: `concatLatestFrom((action) => store.select(selectAlertForFlight(action.flightId)))`, where `selectAlertForFlight` is a factory function returning a selector. `withLatestFrom` cannot do this, because its observable is built once, before any action exists.
- Does concatLatestFrom wait for the selected currency to change before the NgRx effect continues?No. It reads the latest value available when the action arrives; `store.select` emits the current state immediately on subscription, so the tuple is emitted at once. A later currency change does not re-trigger the effect for that action.
saying these in an interview costs you the question
- concatLatestFrom is still imported from @ngrx/effects in current NgRx.
- withLatestFrom only subscribes to the store when the action arrives.
- concatLatestFrom waits for the selector's next change before emitting.
- concatLatestFrom re-runs the effect whenever the selected currency changes.
- The store value read in the effect is from before the reducer handled the action.