NgRx Effect with dispatch: false – ensuring inner observable cleanup
0 reputation · 21 Mar 2023, 04:54 UTC
0 reputation · 21 Mar 2023, 04:54 UTC
When configuring an NgRx Effect with dispatch: false, the effect performs side‑effects such as logging or analytics without emitting new actions.
The effect’s subscription to the global Actions stream is managed by the EffectsModule, which lives for the lifetime of the application.
If the effect’s inner observable (e.g., a timer, websocket, or HTTP request) does not complete on its own, it can keep resources alive and cause a memory leak unless it is cancelled.
The standard RxJS pattern switchMap cancels the previous inner observable when a new action arrives, but it does not guarantee termination when the action stream stops emitting.
Given that the effect remains active for the whole app, developers must decide whether an explicit takeUntil tied to a component‑level destroy signal or the effect’s own lifecycle is required to prevent leaks.
What is the recommended cleanup strategy for dispatch: false effects? Should switchMap alone be sufficient, or is an additional takeUntil (or similar) necessary? How can this be verified in a production‑like build?
For NgRx Effects configured with dispatch: false, switchMap is sufficient only if the inner observable is guaranteed to complete on its own (e.g., a standard Angular HttpClient request). If the inner observable is a long-lived stream—such as a timer, interval, or webSocket—switchMap alone is insufficient because it only cancels the previous inner subscription when a new action arrives. It does not terminate the final active subscription when the application or a specific feature is destroyed.
Because dispatch: false effects typically live for the duration of the providing injector (often the root injector), you must explicitly model the termination of inner resources.
takeUntil inside the inner observable, keyed to a specific "Stop" or "Logout" action. This ensures that even the final active stream is closed.take(1) or first() for side effects that only need to execute once per trigger.timeout operator to prevent orphaned requests from hanging indefinitely in the background.
this.actions$.pipe(
ofType(UserActions.startPolling),
switchMap(() =>
this.pollingService.stream$.pipe(
// Ensure cleanup when a stop action occurs
takeUntil(this.actions$.pipe(ofType(UserActions.stopPolling))),
// Ensure the effect doesn't crash the global stream on error
catchError(() => EMPTY)
)
)
).subscribe(); // Handled by EffectsModule
The EffectsModule manages the subscription to the global Actions stream. When dispatch: false is used, the effect remains active as long as the injector lives. If an inner observable (like a WebSocket) is opened via switchMap and no further actions of that type are dispatched, that socket remains open indefinitely. Since the effect cannot dispatch a "Stop" action to itself, the termination signal must come from an external action or the injector's destruction lifecycle.
To verify that resources are being cleaned up in a production build, use the following scoped methods:
WebSocket or Timer) are no longer retained.finalize(() => console.log('Cleaned up')) block to the inner observable to confirm the teardown logic executes upon both switchMap replacement and explicit termination.Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 21 Mar 2023, 13:31 UTC
A critical distinction here is whether the inner observable is finite or infinite. For standard HttpClient requests, switchMap is sufficient because the observable completes after the response, automatically cleaning up resources.
However, for infinite streams—such as WebSockets, interval, or long-polling—switchMap only cancels the previous subscription when a new action is dispatched. If the user leaves the feature area and no further actions are triggered, the last inner observable remains active for the application's lifetime.
Since root-level effects are singletons and not scoped to components, you cannot use a component's DestroyRef or ngOnDestroy directly. Instead, use a dedicated teardown action:
createEffect(() => this.actions$.pipe( ofType(TriggerAction), switchMap(() => this.infiniteService.stream$.pipe( takeUntil(this.actions$.pipe(ofType(StopAction))) )) ), { dispatch: false })To verify this in production-like builds, use Chrome DevTools Memory snapshots. Trigger the effect and the teardown action repeatedly; a stable count of retained objects confirms the inner subscription is successfully garbage collected.