In an NgRx 22 flight-search app, which flattening operator suits the search, save-booking and login effects, and what breaks if the save effect uses switchMap?
answer
- one effect serves every dispatch
- the operator is a concurrency policy
- latest query, every save, first login
- reducers already saw the dropped action
- every started action needs one outcome
basics
~20 sSearch uses switchMap so a newer query cancels the stale request; save uses concatMap so every booking completes in order; login uses exhaustMap so repeat clicks are ignored. switchMap on save cancels an in-flight save, whose outcome action then never arrives.
solid answer
~40 sAn NgRx effect is one long-lived subscription that handles every dispatch of its action across the whole app, so the flattening operator is that action's concurrency policy. For `queryChanged`, `switchMap` is right: only the newest query matters, and unsubscribing aborts the stale `HttpClient` request. For `saveClicked`, `concatMap` runs every save to completion in order (`mergeMap` if bookings are independent and order is irrelevant). For `loginSubmitted`, `exhaustMap` ignores clicks while a login is in flight. With `switchMap` on save, a second save unsubscribes from the first: its request is aborted in the browser, possibly after the server already stored it, and neither `saveSucceeded` nor `saveFailed` is dispatched for it, so the reducer that marked that booking as saving never hears back.
code
ts · 33 linesimport { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of, switchMap } from 'rxjs';
import { AuthApi, FlightApi } from './api';
import { AuthActions, FlightSearchActions } from './actions';
export const search = createEffect(
(actions$ = inject(Actions), flightApi = inject(FlightApi)) =>
actions$.pipe(
ofType(FlightSearchActions.queryChanged),
switchMap(({ query }) =>
flightApi.search(query).pipe(
map((flights) => FlightSearchActions.searchSucceeded({ flights })),
catchError(() => of(FlightSearchActions.searchFailed()))
)
)
),
{ functional: true }
);
export const login = createEffect(
(actions$ = inject(Actions), authApi = inject(AuthApi)) =>
actions$.pipe(
ofType(AuthActions.loginSubmitted),
exhaustMap(({ credentials }) =>
authApi.login(credentials).pipe(
map((user) => AuthActions.loginSucceeded({ user })),
catchError(() => of(AuthActions.loginFailed()))
)
)
),
{ functional: true }
);go deeper
Recall the four pairings: search with switchMap, save with concatMap, login with exhaustMap, and that the operator sits between ofType and the request.
Explain that one effect handles every dispatch app-wide, so the operator decides what happens to a request still running when the same action arrives again.
Diagnose the state bug: reducers mark work as started before the effect runs, so cancelling or dropping inside the effect leaves an item waiting for an outcome action that never comes.
Make the concurrency policy explicit per action in review and design reducers so every started action can reach one success or failure action under that policy.
## Why the operator matters more in an effect An **effect** in NgRx listens to the `Actions` stream for the whole lifetime of the application or feature. The same `saveBooking` effect handles a save from the booking page, from the trip summary and from a keyboard shortcut. Between `ofType(...)` and the request sits a **flattening operator** (`switchMap`, `concatMap`, `mergeMap` or `exhaustMap`) that decides what happens when a new action arrives while the previous request is still running. In an effect, that choice is the **concurrency policy for one action type, app-wide**. Two NgRx facts make a wrong choice visible in the state: - **Reducers run before effects.** Every `saveClicked` has already been through the reducers, which typically set a `saving` flag for that booking, before the effect decides whether to run, queue, cancel or drop it. - **The effect usually ends the story.** The reducer clears the flag when `saveSucceeded` or `saveFailed` arrives. An operator that cancels or drops work leaves a started action with no outcome action. ## The flight-search app, effect by effect | Effect (trigger) | Operator | Why | What the wrong one does | |---|---|---|---| | Search (`queryChanged`) | `switchMap` | Only the latest query's results matter | `mergeMap` lets an older, slower response dispatch `searchSucceeded` after a newer one and overwrite it | | Save booking (`saveClicked`) | `concatMap` | Every save must finish, in dispatch order | `switchMap` cancels an in-flight save; `exhaustMap` drops a save the reducer already marked | | Login (`loginSubmitted`) | `exhaustMap` | The first attempt wins; double-clicks are ignored | `switchMap` aborts and restarts the login; `mergeMap` sends two logins | | Price alert (`priceAlertStarted`) | `switchMap` over a timer | A new alert replaces the old polling loop | `mergeMap` stacks a second loop beside the first | `mergeMap` is also valid for saves when bookings are independent and their completion order does not matter; it runs them in parallel instead of queueing them. ## What switchMap does to a save Suppose the traveller saves booking A and, while that request is in flight, saves booking B: 1. `saveClicked(A)` reaches the reducer, which marks A as saving; the effect starts the request for A. 2. `saveClicked(B)` reaches the reducer, which marks B as saving; `switchMap` **unsubscribes** from A's inner observable and starts B's. 3. Unsubscribing from an `HttpClient` observable aborts the request in the browser. The server may or may not have applied A already; the client cannot know. 4. B's response produces `saveSucceeded(B)`. **Nothing is ever dispatched for A**, so A stays marked as saving and the traveller cannot tell whether it was stored. `exhaustMap` fails differently but just as quietly: it ignores `saveClicked(B)` while A is running, yet the reducer has already marked B as saving. ## Polling the price alert A polling effect nests two decisions: `switchMap` on `priceAlertStarted` so a new alert replaces the running loop, and inside it `timer(0, 60_000)` feeding `exhaustMap` so a slow price check is not overlapped by the next tick. A `takeUntil(actions$.pipe(ofType(PriceAlertActions.stopped)))` on the inner loop stops it when the traveller cancels the alert. ## Reviewing an existing effect When an interviewer hands you an effect to review, ask four questions in order: 1. **What triggers it?** The action type in `ofType`, and every place that dispatches it. 2. **Can it arrive again while the request runs?** Typing, double-clicks and retries all say yes. 3. **What should the newer action do?** Replace the running one, wait behind it, run beside it, or be ignored. That answer names the operator. 4. **Does every started action still end?** Under that operator, each action the reducer marked as started must be able to reach a success or failure action. ## Keeping the operator honest - Keep the request's `map` and `catchError` inside the flattening operator's inner observable, so a failure produces `saveFailed` instead of ending the effect. - Name the policy in review: "latest wins", "all in order", "all in parallel", "first wins". - If the reducer tracks per-item status, make sure every started action can still reach a success or failure action under the chosen operator. ## Summary - Search: `switchMap`. - Save: `concatMap`, or `mergeMap` for independent bookings; not `switchMap` or `exhaustMap`. - Login: `exhaustMap`. - Polling: `switchMap` from the start action into a timer, stopped by a stop action.
- The traveller cancels a price alert; how does the NgRx polling effect stop without ending the effect itself?Put `takeUntil(actions$.pipe(ofType(PriceAlertActions.stopped)))` on the inner timer observable inside `switchMap`, not on the outer `actions$` pipe. The inner loop completes on `stopped`, while the outer effect keeps listening, so a later `priceAlertStarted` starts a new loop.
- When is mergeMap a better choice than concatMap for the NgRx save-booking effect?When saves concern different bookings, do not depend on each other and their completion order does not matter. `mergeMap` runs them in parallel, so one slow save does not delay the others, and every one still dispatches its own success or failure action. Saves to the same booking usually still need ordering.
saying these in an interview costs you the question
- switchMap is always the safe default for every NgRx effect.
- exhaustMap on a save effect is fine because the reducer ignores the dropped action.
- switchMap cancels the earlier save's request, so the server is sure never to store it.
- Each dispatch gets its own effect instance, so the operator choice does not matter.
- mergeMap on a search effect keeps results in the order the queries were typed.