Skip to main content

Overview

Effects provide a way to isolate side effects (API calls, logging, navigation, etc.) from components and facades. They listen to actions dispatched to the store and trigger side effects in response.
Rodando Passenger uses NgRx Effects selectively for cross-cutting concerns like notifications and analytics. Most side effects are handled directly in facades.

When to Use Effects

Use Effects for:
  • Cross-cutting concerns: Logging, analytics, notifications
  • Action chaining: Trigger multiple actions in response to one
  • External integrations: Third-party services, analytics platforms
  • Debouncing/throttling: Rate-limiting API calls
Don’t overuse Effects. For simple workflows, handle side effects in facades. Effects add complexity and should be reserved for scenarios where they provide clear value.

Notification Alert Effects

The primary use of Effects in Rodando Passenger is for displaying toast notifications in response to various events.

Effect Implementation

src/app/store/notification-alerts/notification-alert.effects.ts
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs';
import { ToastController } from '@ionic/angular';
import * as NotificationActions from './notification-alert.actions';

export class NotificationAlertEffects {
  private actions$ = inject(Actions);
  private toastController = inject(ToastController);

  showNotification$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(NotificationActions.showNotificationAlert),
        tap(async (action) => {
          const toast = await this.toastController.create({
            message: action.message,
            duration: action.duration ?? 3000,
            position: action.position ?? 'bottom',
            color: action.color ?? 'primary',
            buttons: action.dismissible
              ? [{ text: 'Dismiss', role: 'cancel' }]
              : undefined,
          });
          await toast.present();
        })
      ),
    { dispatch: false }
  );
}

Action Definition

src/app/store/notification-alerts/notification-alert.actions.ts
import { createAction, props } from '@ngrx/store';

export const showNotificationAlert = createAction(
  '[Notification] Show Alert',
  props<{
    message: string;
    duration?: number;
    position?: 'top' | 'bottom' | 'middle';
    color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
    dismissible?: boolean;
  }>()
);

Usage in Facades

Facades dispatch notification actions:
src/app/store/auth/auth.facade.ts
import { Store } from '@ngrx/store';
import { showNotificationAlert } from '../notification-alerts/notification-alert.actions';

export class AuthFacade {
  private store = inject(Store);

  login(credentials: LoginPayload): Observable<User> {
    return this.authService.login(credentials).pipe(
      tap(() => {
        this.store.dispatch(
          showNotificationAlert({
            message: 'Welcome back!',
            color: 'success',
            duration: 2000,
          })
        );
      }),
      catchError((err) => {
        this.store.dispatch(
          showNotificationAlert({
            message: err.message ?? 'Login failed',
            color: 'danger',
            duration: 4000,
          })
        );
        return throwError(() => err);
      })
    );
  }
}

Effect Patterns

Non-Dispatching Effects

Effects that don’t dispatch new actions (like showing toasts) must set { dispatch: false }:
showNotification$ = createEffect(
  () => this.actions$.pipe(
    ofType(NotificationActions.showNotificationAlert),
    tap(async (action) => {
      // Side effect logic here
    })
  ),
  { dispatch: false } // ⚠️ Required!
);

Action Chaining

Effects can dispatch multiple actions in response:
loginSuccess$ = createEffect(() =>
  this.actions$.pipe(
    ofType(AuthActions.loginSuccess),
    concatMap((action) => [
      showNotificationAlert({ message: 'Login successful', color: 'success' }),
      AnalyticsActions.trackEvent({ event: 'login', userId: action.user.id }),
      NavigationActions.navigate({ path: '/home' }),
    ])
  )
);

Debouncing API Calls

Effects can debounce rapid user input:
searchDestination$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TripActions.searchDestinationInput),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((action) =>
      this.mapboxService.search(action.query).pipe(
        map((results) => TripActions.searchDestinationSuccess({ results })),
        catchError((error) => of(TripActions.searchDestinationFailure({ error })))
      )
    )
  )
);

Error Handling

Effects should handle errors gracefully:
loadTrips$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TripActions.loadTrips),
    switchMap(() =>
      this.tripsService.getTrips().pipe(
        map((trips) => TripActions.loadTripsSuccess({ trips })),
        catchError((error: ApiError) => {
          // Log error, show notification, etc.
          this.logger.error('Failed to load trips', error);
          return of(TripActions.loadTripsFailure({ error }));
        })
      )
    )
  )
);

Registering Effects

Effects must be registered in the app configuration:
src/app/app.config.ts
import { provideEffects } from '@ngrx/effects';
import { NotificationAlertEffects } from './store/notification-alerts/notification-alert.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideEffects([NotificationAlertEffects]),
    // ... other providers
  ],
};

Testing Effects

Effects can be tested using provideMockActions:
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of } from 'rxjs';

describe('NotificationAlertEffects', () => {
  let actions$: Observable<any>;
  let effects: NotificationAlertEffects;
  let toastController: jasmine.SpyObj<ToastController>;

  beforeEach(() => {
    actions$ = provideMockActions(() => of());
    toastController = jasmine.createSpyObj('ToastController', ['create']);

    effects = new NotificationAlertEffects(actions$, toastController);
  });

  it('should show a toast when showNotificationAlert is dispatched', (done) => {
    const mockToast = jasmine.createSpyObj('Toast', ['present']);
    toastController.create.and.returnValue(Promise.resolve(mockToast));

    actions$ = of(
      showNotificationAlert({ message: 'Test message', color: 'success' })
    );

    effects.showNotification$.subscribe(() => {
      expect(toastController.create).toHaveBeenCalledWith({
        message: 'Test message',
        color: 'success',
        duration: 3000,
        position: 'bottom',
        buttons: undefined,
      });
      expect(mockToast.present).toHaveBeenCalled();
      done();
    });
  });
});

Effects vs Facades

  • Cross-cutting concerns: Logging, analytics, notifications that span multiple features
  • Action chaining: Need to dispatch multiple actions in response to one
  • Debouncing/throttling: Rate-limiting user input or API calls
  • Global event handling: Browser events, WebSocket messages

Best Practices

Keep Effects Focused: Each effect should handle a single concern. Don’t create “god effects” that handle many action types.
Use switchMap for Cancellable Requests: If a user triggers the same action multiple times (e.g., search), use switchMap to cancel previous requests.
Avoid Infinite Loops: Be careful when dispatching actions from effects that listen to those same actions. Use guards or different action types.

Stores

Signal-based state stores

Facades

Business logic orchestration layer

State Management

Overall state management architecture

Build docs developers (and LLMs) love