In NgRx 22, when does an effect need { dispatch: false }, and why does a navigate-after-booking effect built on tap loop without it?
answer
- what createEffect does with emissions
- tap passes its input through
- the effect hears its own output
- ignoreElements on the effect's stream
- a lint rule for cyclic effects
basics
~20 sAn NgRx effect dispatches every value it emits unless created with { dispatch: false }. An effect that only navigates or logs through tap re-emits the action it received, so without that flag it dispatches the same action again and loops.
solid answer
~40 sBy default `createEffect` uses `dispatch: true`: whatever the effect's observable emits is dispatched back to the store. A navigation effect such as `actions$.pipe(ofType(BookingApiActions.saveSucceeded), tap(() => router.navigate(['/trips'])))` emits the very `saveSucceeded` action it received, because `tap` passes values through unchanged. With the default config that action is dispatched again, the same effect hears it again, and the cycle never ends, freezing the tab. Adding `{ dispatch: false }` (or `{ functional: true, dispatch: false }` for a functional effect) makes NgRx discard everything the effect emits. Use it for effects whose job is the side effect itself: navigation, toasts, analytics, writing to storage. The `@ngrx/avoid-cyclic-effects` ESLint rule catches the looping version.
code
ts · 15 linesimport { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs';
import { BookingApiActions } from './booking.actions';
export const goToTripsAfterSave = createEffect(
(actions$ = inject(Actions), router = inject(Router)) => {
return actions$.pipe(
ofType(BookingApiActions.saveSucceeded),
tap(() => router.navigate(['/trips']))
);
},
{ functional: true, dispatch: false }
);go deeper
Recall that an effect dispatches whatever it emits unless dispatch: false is set, and that navigation or logging effects need that flag.
Trace why tap re-emits the trigger action, how that becomes an endless dispatch loop, and what NgRx does differently with a dispatch: false effect's output.
Show you guard against the class of bug: the avoid-cyclic-effects and no-dispatch-in-effects rules, and effects that map one action to one result.
Frame it as a team convention: every effect either maps to exactly one outcome action or is explicitly non-dispatching, enforced by lint so reviews do not rely on memory.
## What NgRx does with an effect's output An **effect** is an observable registered through `createEffect` and `provideEffects`. NgRx subscribes to it and, by default, **dispatches every value it emits** as an action. The default configuration of `createEffect` is: | Option | Default | Meaning | |---|---|---| | `dispatch` | `true` | Emitted values are dispatched to the store | | `functional` | `false` | The effect is a class property, not a standalone function | | `useEffectsErrorHandler` | `true` | NgRx resubscribes after an unhandled error | `dispatch: true` is right for request effects: they turn one action into a result action. But some effects exist only to *do* something, with nothing to report back. ## The loop, step by step In the flight-search app, after a booking is saved the traveller should land on the trips page: ```ts export const goToTrips = createEffect( (actions$ = inject(Actions), router = inject(Router)) => actions$.pipe( ofType(BookingApiActions.saveSucceeded), tap(() => router.navigate(['/trips'])) ), { functional: true } // dispatch: false is missing ); ``` 1. The booking effect dispatches `saveSucceeded`. 2. `goToTrips` receives it; `tap` runs the navigation and passes the **same action** downstream unchanged. 3. Because `dispatch` defaults to `true`, NgRx dispatches `saveSucceeded` again. 4. The reducers run again, then the `Actions` stream delivers it to `goToTrips` again, which re-emits it, and so on. The NgRx documentation describes the result plainly: the effect is subscribing to and dispatching the same action, which causes an infinite loop and crashes the browser tab. TypeScript does not save you here: the effect still emits a valid action, so it type-checks. ## What `dispatch: false` changes With `{ dispatch: false }` NgRx pipes the effect's stream through `ignoreElements()` before merging it with the others, so nothing it emits reaches the store. It also relaxes the typing, so the effect may emit any value, not only actions. For a functional effect the config is `{ functional: true, dispatch: false }`. Good candidates for a non-dispatching effect: - **Navigation** after an action, as above. - **Notifications**: a toast when `BookingApiActions.saveFailed` arrives. - **Analytics or logging** of selected actions. - **Persistence**, such as writing the last search query to `localStorage`. - **Effects on non-action sources**, for example one built on `fromEvent(document, 'click')` that reports activity and has nothing to dispatch. The error-handling default is unaffected: a non-dispatching effect is still resubscribed after an unhandled error unless you also set `useEffectsErrorHandler: false`. ## Deciding: act only, or report back? | Need in the flight app | Config | Effect body | |---|---|---| | Go to the trips page after a save | `dispatch: false` | `tap(() => router.navigate(...))` | | Toast when a save fails | `dispatch: false` | `tap(({ message }) => toast.show(message))` | | Remember the last search query | `dispatch: false` | `tap(({ query }) => localStorage.setItem(...))` | | Save the booking and tell the store | default `dispatch: true` | flatten into the request, `map` to `saveSucceeded` | The test is simple: if the store must learn something from the effect, it maps to a new, **different** action and keeps the default; if the effect's job ends with the side effect, it declares `dispatch: false`. ## Neighbouring mistakes - **Dispatching from inside `tap`**: calling `store.dispatch(...)` inside an effect instead of mapping to an action hides the data flow. The `@ngrx/no-dispatch-in-effects` rule flags it; map to an action instead. - **Emitting something that is not an action** while `dispatch` is still `true`: with correct typing this is a compile error; if the types are bypassed, NgRx reports *dispatched an invalid action* through Angular's `ErrorHandler` at runtime. - **Mapping to the same action on purpose**: rare, and the reason `@ngrx/avoid-cyclic-effects` (a type-checked rule) exists. It skips effects configured with `dispatch: false` and can be disabled for the unusual case where re-dispatching is intended. ## How to answer in an interview - Default `dispatch: true` means "every emission is an action to dispatch". - `tap` passes the input through, so an effect that only performs a side effect with `tap` re-emits its trigger. - `{ dispatch: false }` discards the output and is the right setting for navigation, notifications, logging and persistence. - Prefer mapping one action to one result action for effects that do report back.
- Does { dispatch: false } also switch off NgRx's resubscription after an error?No. `dispatch` and `useEffectsErrorHandler` are independent options. A non-dispatching effect whose stream errors is still reported to Angular's `ErrorHandler` and resubscribed by the default effects error handler, unless you also pass `useEffectsErrorHandler: false`.
- Why not call store.dispatch inside tap instead of mapping to an action?It works mechanically but hides the flow: the effect's output no longer shows what it causes, and it is easy to dispatch several actions or loop. NgRx's `@ngrx/no-dispatch-in-effects` lint rule flags it. Map to one result action with `dispatch: true`, or use `dispatch: false` when there is nothing to dispatch.
saying these in an interview costs you the question
- tap returns nothing, so a tap-only effect never dispatches anything.
- strictActionTypeUniqueness stops the same action being dispatched twice in a row.
- dispatch: false also disables the resubscription after an error.
- Emitting the same action again is harmless because the reducer is idempotent.
- A non-dispatching effect should call store.dispatch when it needs to report back.