NgRx Effects: A Comprehensive Guide
Amos Isaila

Amos Isaila @amosisaila

About: Angular enthusiast. https://www.youtube.com/channel/UCATsbPmCzU5dVL_sbNwbb5w

Location:
Spain
Joined:
May 8, 2017

NgRx Effects: A Comprehensive Guide

Publish Date: Aug 3
0 0

NgRx Effects are a powerful feature in the NgRx library for Angular applications. They provide a way to handle side effects, such as data fetching or interactions with browser API in a clean way.

Why We Need NgRx Effects

NgRx Effects are crucial in complex Angular applications for several reasons:

  1. Separation of concerns : effects keep side effects out of your components and reducers, adhering to the single responsibility principle.
  2. Centralized side effect management : all side effects are handled in one place, making it easier to understand and maintain your application’s behavior.
  3. Improved testability : by isolating side effects, you can easily mock and test them without affecting the rest of your application.
  4. Better error handling : effects provide a consistent way to handle errors that occur during side effects.
  5. Cancellation and debouncing : effects make it easy to implement advanced patterns like cancellation of in-flight requests or debouncing of frequent actions.
  6. Reactive programming : effects leverage RxJS, allowing you to use powerful reactive programming techniques.

Let’s cover the installation and setup process for different Angular project structures:

Using Angular CLI

ng add @ngrx/effects@latest
Enter fullscreen mode Exit fullscreen mode

For configurations check this page.

For NgModules

First, install the package:

npm install @ngrx/effects
// or
yarn add @ngrx/effects
Enter fullscreen mode Exit fullscreen mode

Then, import EffectsModule in your AppModule:

import { EffectsModule } from '@ngrx/effects';
import { PhotosEffects } from './photos.effects';

@NgModule({
  imports: [
    EffectsModule.forRoot([PhotosEffects])
  ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

For Standalone Applications

In a standalone app, you can provide effects using provideEffects:

import { provideEffects } from '@ngrx/effects';
import { PhotosEffects } from './photos.effects';

bootstrapApplication(AppComponent, {
  providers: [
    provideEffects(PhotosEffects)
  ]
});
Enter fullscreen mode Exit fullscreen mode

How createEffect works under the hood?

When you define an effect like this:

readonly loadMorePhotos$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(loadMorePhotos),
    withLatestFrom(this._store.select(selectAllPhotos)),
    concatMap(([action, photos]) => {
      // ... effect logic ...
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

The createEffect function is called with two arguments:

  • A source function that returns an Observable
  • actions$ is an RxJS Observable that emits every action dispatched in your NgRx store. It's typically injected into your effects class and is provided by the NgRx Effects module.
  • The ofType operator filters this stream to only emit actions of type loadMorePhotos.
  • You can use actions$ with other RxJS operators to create more complex effects. For example:
// This effect responds to two different action types and combines
// the action with the latest state before performing an async operation.

readonly complexEffect$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(actionOne, actionTwo),
    withLatestFrom(this.store.select(someSelector)),
    switchMap(([action, state]) => {
      // Perform some async operation based on the action and current state
    })
  );
});
Enter fullscreen mode Exit fullscreen mode
  • An optional configuration object (not provided in this example (EffectConfig), so default values are used)
  • The EffectConfig interface defines three optional properties:
  • dispatch: determines if the actions emitted by the effect should be dispatched to the store.
  • functional: indicates if the effect is a functional effect (created outside an effects class).
  • useEffectsErrorHandler: determines if the effect should use NgRx's built-in error handler.
  • These properties have default values defined in DEFAULT_EFFECT_CONFIG:
const DEFAULT_EFFECT_CONFIG: Readonly<Required<EffectConfig>> = {
  dispatch: true,
  functional: false,
  useEffectsErrorHandler: true,
};
Enter fullscreen mode Exit fullscreen mode

The source function is not immediately executed. Instead, createEffect returns an object with some additional metadata attached to it using Object.defineProperty.

The metadata is stored under a symbol (CREATE_EFFECT_METADATA_KEY) and includes the configuration for this effect.

When the NgRx effects system initializes:

  • It uses getCreateEffectMetadata to find all properties in your PhotosEffects class that have this special metadata.
  • For each effect found, it sets up subscriptions based on the configuration.

When your effect is triggered (i.e., when a loadMorePhotos action is dispatched):

  • The source function is executed, creating the Observable chain.
  • The actions stream is filtered for the loadMorePhotos action.
  • The latest state is combined with the action using withLatestFrom.
  • The concatMap operator is used to handle the side effect (fetching photos) and map the result to new actions.

The resulting actions from your effect (loadMorePhotosSuccess and updateTotalPhotos) are then dispatched back to the store, because the default dispatch: true configuration was used.

If an error occurs during the effect execution, it’s handled by the NgRx effects error handler (because useEffectsErrorHandler: true).

The beauty of this system is that it allows NgRx to manage the lifecycle of your effects, ensuring they’re properly set up, torn down, and integrated with the rest of the NgRx ecosystem.

When you use provideEffects(PhotosEffects) in your app's providers, NgRx instantiates your PhotosEffects class and sets up all these effects to run in response to dispatched actions.

This declarative approach to side effects keeps your components clean and your business logic centralized and testable, while still allowing for complex asynchronous workflows in your application.

Functional Effects

Let’s explain why Functional effects can be beneficial and in which cases:

  1. They are often more concise and easier to read, especially for simpler effects. They remove the need for a class and constructor, making the code more straightforward.
  2. They can be easier to test because you don’t need to create an instance of a class. You can simply call the effect function with mocked dependencies.
  3. Functional effects can potentially lead to better tree-shaking in your application. Unused effects can be more easily identified and removed from the final bundle.

Note: if you need to use NgRx’s effect lifecycle hooks (ngrxOnInitEffects, ngrxOnRunEffects, etc.), you'll need to use class-based effects.

How we can convert our example into a functional effect:

import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { catchError, concatMap, map, mergeMap, of, withLatestFrom } from 'rxjs';
import { PhotosService } from '../modules/components/photos/photos.service';
import { updateTotalPhotos } from './app.actions';
import { filterPhotos, loadMorePhotos, loadMorePhotosFailure, loadMorePhotosSuccess, setFilteredPhotos, setItemsBeingFiltered } from './photos.actions';
import { selectAllPhotos } from './photos.selectors';

export const loadMorePhotos$ = createEffect(
  (
    actions$ = inject(Actions),
    store = inject(Store),
    photosService = inject(PhotosService)
  ) => {
    return actions$.pipe(
      ofType(loadMorePhotos),
      withLatestFrom(store.select(selectAllPhotos)),
      concatMap(([action, photos]) => {
        const { total } = action;
        return photosService.getRandomPhotos(total).pipe(
          map((newPhotos) => {
            return [
              loadMorePhotosSuccess({ newPhotos }),
              updateTotalPhotos({ totalPhotos: photos.length + newPhotos.length })
            ]
          }),
          mergeMap(actions => actions),
          catchError(() => of(loadMorePhotosFailure()))
        );
      })
    );
  },
  { functional: true }
);

export const filterPhotos$ = createEffect(
  (actions$ = inject(Actions), store = inject(Store)) => {
    return actions$.pipe(
      ofType(filterPhotos),
      withLatestFrom(store.select(selectAllPhotos)),
      map(([action, photos]) => {
        const { searchTerm } = action;
        if (!searchTerm) {
          return [setFilteredPhotos({ filteredPhotos: photos })];
        }
        const filteredPhotos = photos.filter((p) => p.id.includes(searchTerm));
        return [
          setFilteredPhotos({ filteredPhotos }),
          setItemsBeingFiltered({ totals: filteredPhotos.length }),
          updateTotalPhotos({ totalPhotos: photos.length })
        ];
      }),
      mergeMap(actions => actions)
    );
  },
  { functional: true }
);
Enter fullscreen mode Exit fullscreen mode

Effects Lifecycle

Effects have a lifecycle that you can tap into for additional control:

ROOT_EFFECTS_INIT

After all root effects have been added, NgRx dispatches a ROOT_EFFECTS_INIT action. You can use this as a lifecycle hook to execute code after all root effects have been added:

init$ = createEffect(() => 
  this.actions$.pipe(
    ofType(ROOT_EFFECTS_INIT),
    map(action => /* perform some initialization */)
  )
);
Enter fullscreen mode Exit fullscreen mode

OnInitEffects

Implement this interface to dispatch a custom action after the effect has been added:

class PhotosEffects implements OnInitEffects {
  ngrxOnInitEffects(): Action {
    return { type: '[PhotosEffects]: Init' };
  }
}
Enter fullscreen mode Exit fullscreen mode

OnRunEffects

Implement this interface to control the lifecycle of resolved effects:

@Injectable()
export class PhotosEffects implements OnRunEffects {
  constructor(private actions$: Actions) {}

  ngrxOnRunEffects(resolvedEffects$: Observable<EffectNotification>) {
    return this.actions$.pipe(
      ofType('LOGGED_IN'),
      exhaustMap(() =>
        resolvedEffects$.pipe(
          takeUntil(this.actions$.pipe(ofType('LOGGED_OUT')))
        )
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Effect Metadata

Non-dispatching Effects

Sometimes you don’t want effects to dispatch an action. Add { dispatch: false } to the createEffect function as the second argument:

logActions$ = createEffect(() =>
  this.actions$.pipe(
    tap(action => console.log(action))
  ), { dispatch: false });
Enter fullscreen mode Exit fullscreen mode

Resubscribe on Error

By default, effects are resubscribed up to 10 errors. To disable resubscriptions, add { useEffectsErrorHandler: false } to the createEffect metadata:

logins$ = createEffect(
  () =>
    this.actions$.pipe(
      ofType(LoginPageActions.login),
      exhaustMap(action =>
        this.authService.login(action.credentials).pipe(
          map(user => AuthApiActions.loginSuccess({ user })),
          catchError(error => of(AuthApiActions.loginFailure({ error })))
        )
      )
    ),
  { useEffectsErrorHandler: false }
);
Enter fullscreen mode Exit fullscreen mode

Testing Effects

Testing effects is crucial for ensuring the reliability of your application. Here’s how you can test the PhotosEffects:

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of } from 'rxjs';
import { PhotosEffects } from './photos.effects';
import { PhotosService } from '../modules/components/photos/photos.service';
import { Store } from '@ngrx/store';
import { loadMorePhotos, loadMorePhotosSuccess } from './photos.actions';

describe('PhotosEffects', () => {
  let actions$: Observable<any>;
  let effects: PhotosEffects;
  let photosService: jasmine.SpyObj<PhotosService>;
  let store: jasmine.SpyObj<Store>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        PhotosEffects,
        provideMockActions(() => actions$),
        {
          provide: PhotosService,
          useValue: jasmine.createSpyObj('PhotosService', ['getRandomPhotos'])
        },
        {
          provide: Store,
          useValue: jasmine.createSpyObj('Store', ['select'])
        }
      ]
    });

    effects = TestBed.inject(PhotosEffects);
    photosService = TestBed.inject(PhotosService) as jasmine.SpyObj<PhotosService>;
    store = TestBed.inject(Store) as jasmine.SpyObj<Store>;
  });

  it('should load more photos successfully', (done) => {
    const mockPhotos = [{ id: '1', url: 'url1' }, { id: '2', url: 'url2' }];
    actions$ = of(loadMorePhotos({ total: 2 }));
    photosService.getRandomPhotos.and.returnValue(of(mockPhotos));
    store.select.and.returnValue(of([]));

    effects.loadMorePhotos$.subscribe(action => {
      expect(action).toEqual(loadMorePhotosSuccess({ newPhotos: mockPhotos }));
      done();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This test verifies that the loadMorePhotos$ effect correctly handles the loadMorePhotos action and dispatches a loadMorePhotosSuccess action with the new photos.

withLatestFrom or concatLatestFrom

In our loadMorePhotos$ effect, we are using withLatestFrom like this:

export const loadMorePhotos$ = createEffect(
  (
    actions$ = inject(Actions),
    store = inject(Store),
    photosService = inject(PhotosService)
  ) => {
    return actions$.pipe(
      ofType(loadMorePhotos),
      withLatestFrom(store.select(selectAllPhotos)),
      concatMap(([action, photos]) => {
        // ... effect logic ...
      })
    );
  },
  { functional: true }
);
Enter fullscreen mode Exit fullscreen mode
  • withLatestFrom combines the latest value from store.select(selectAllPhotos) with each loadMorePhotos action.
  • The resulting array contains the action as its first element and the latest state (all photos) as its second element.
  • This allows you to access both the action data and the current state in your effect logic.

You can do the same by using the concatLatestFrom operator provided by @ngrx/effects.

// ...
ofType(loadMorePhotos),
concatLatestFrom(() => store.select(selectAllPhotos)),
concatMap(([action, photos]) => {
// ...
Enter fullscreen mode Exit fullscreen mode

The key differences are:

  • concatLatestFrom takes a function that returns the observable to combine with the action.
  • It’s more efficient because it only selects from the store when the action occurs, not on every state change.

When to use which:

  • Use withLatestFrom when you need the latest state value for every action, regardless of how frequently the state changes.
  • Use concatLatestFrom when you only need the state at the moment the action occurs. This can be more performant, especially if the state changes frequently.

Effects Operators

NgRx provides some useful operators for working with effects:

ofType

The ofType operator filters the stream of actions based on action types:

import { ofType } from '@ngrx/effects';

this.actions$.pipe(
  ofType(loadMorePhotos),
  // ... rest of the effect logic
)
Enter fullscreen mode Exit fullscreen mode

You can use ofType with multiple action types:

ofType(loadMorePhotos, filterPhotos)
Enter fullscreen mode Exit fullscreen mode

Registering Root and Feature Effects

Proper registration of effects is crucial for ensuring that your NgRx application functions correctly. Let’s explore how to register both root and feature effects in various Angular application setups.

Registering Root Effects

Root effects are typically application-wide effects that need to be available throughout your entire app.

In Traditional Module-based Applications

For module-based applications, register root effects in your AppModule:

import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { PhotosEffects } from './effects/photos.effects';
import * as userEffects from './effects/user.effects';

@NgModule({
  imports: [
    EffectsModule.forRoot([PhotosEffects, userEffects]),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Note: Even if you don’t have any root-level effects, you should still call EffectsModule.forRoot() in your AppModule to set up the effects system.

In Standalone Applications

For applications using Angular’s standalone features, register root effects in your main.ts:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { AppComponent } from './app.component';
import { PhotosEffects } from './effects/photos.effects';
import * as userEffects from './effects/user.effects';

bootstrapApplication(AppComponent, {
  providers: [
    provideStore(),
    provideEffects(PhotosEffects, userEffects),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Registering Feature Effects

Feature effects are specific to particular features or modules in your application.

In Traditional Module-based Applications

Register feature effects in the relevant feature module:

import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { PhotosEffects } from './effects/photos.effects';

@NgModule({
  imports: [
    EffectsModule.forFeature([PhotosEffects])
  ],
})
export class PhotosModule {}
Enter fullscreen mode Exit fullscreen mode

In Standalone Applications

For standalone applications, register feature effects in the route configuration:

import { Route } from '@angular/router';
import { provideEffects } from '@ngrx/effects';
import { PhotosEffects } from './effects/photos.effects';

export const routes: Route[] = [
  {
    path: 'photos',
    providers: [
      provideEffects(PhotosEffects)
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

Alternative Registration Method

You can also register effects using the USER_PROVIDED_EFFECTS token:

import { USER_PROVIDED_EFFECTS } from '@ngrx/effects';
import { PhotosEffects } from './effects/photos.effects';

@NgModule({
  providers: [
    PhotosEffects,
    {
      provide: USER_PROVIDED_EFFECTS,
      multi: true,
      useValue: [PhotosEffects],
    },
  ]
})
export class PhotosModule {}
Enter fullscreen mode Exit fullscreen mode

Note: When using this method, you still need to include EffectsModule.forFeature() or provideEffects() in your module imports or route configuration.

Mixing Module-based and Standalone Approaches

If you’re using standalone components within a module-based application, you can combine both approaches:

import { NgModule } from '@angular/core';
import { EffectsModule, provideEffects } from '@ngrx/effects';
import { PhotosEffects } from './effects/photos.effects';

@NgModule({
  imports: [
    EffectsModule.forRoot([PhotosEffects]),
  ],
  providers: [
    provideEffects(PhotosEffects)
  ]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

This setup ensures that both module-based and standalone components can access the effects.

Key Points to Remember

  1. Root effects should be registered once in your application, typically in AppModule or main.ts.
  2. Feature effects are registered in their respective feature modules or route configurations.
  3. Effects start running immediately after instantiation.
  4. Registering an effect multiple times (e.g., in different lazy-loaded modules) will not cause it to run multiple times.
  5. For applications mixing module-based and standalone approaches, you may need to use both EffectsModule.forRoot() and provideEffects() in your AppModule.

Effect vs Selector: Optimizing State Management

When working with NgRx, it’s crucial to understand the distinct roles of Effects and Selectors. Misusing these tools can lead to unnecessary complexity and potential performance issues. Let’s explore this concept using our photos application example.

Selectors are pure functions used to derive state. They should be your go-to tool when you need to transform or combine data that already exists in the store.

Effects are used for handling side effects in your application, such as API calls, long-running tasks, or interactions with browser APIs. They should not be used for data transformation that can be done with selectors.

There is a challenge about this and I made a video about it.

ComponentStore Effects: Local State Management with Side Effects

While NgRx effects are great for managing global side effects, sometimes we need to handle side effects at a component level. This is where ComponentStore effects come in handy. Let’s explore how we can implement ComponentStore effects in our photos application.

Implementing ComponentStore for Photos

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Observable, EMPTY } from 'rxjs';
import { tap, switchMap, catchError } from 'rxjs/operators';
import { Photo, PhotosService } from './photos.service';

interface PhotosState {
  photos: Photo[];
  searchTerm: string;
}

@Injectable()
export class PhotosComponentStore extends ComponentStore<PhotosState> {
  constructor(private photosService: PhotosService) {
    super({ photos: [], searchTerm: '' });
  }

  // Selectors
  readonly photos$ = this.select(state => state.photos);
  readonly searchTerm$ = this.select(state => state.searchTerm);
  readonly filteredPhotos$ = this.select(
    this.photos$,
    this.searchTerm$,
    (photos, searchTerm) => 
      photos.filter(photo => photo.id.includes(searchTerm))
  );

  // Updaters
  readonly setSearchTerm = this.updater((state, searchTerm: string) => ({
    ...state,
    searchTerm
  }));

  readonly addPhotos = this.updater((state, newPhotos: Photo[]) => ({
    ...state,
    photos: [...state.photos, ...newPhotos]
  }));

  // Effects
  readonly loadMorePhotos = this.effect((total$: Observable<number>) => {
    return total$.pipe(
      switchMap((total) => 
        this.photosService.getRandomPhotos(total).pipe(
          tap(newPhotos => this.addPhotos(newPhotos)),
          catchError(error => {
            console.error('Error loading photos', error);
            return EMPTY;
          })
        )
      )
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

In this implementation:

  1. We define a PhotosState interface to represent our local state.
  2. We create selectors for photos, searchTerm, and filteredPhotos.
  3. We define updaters for setting the search term and adding new photos.
  4. We implement a loadMorePhotos effect to handle loading more photos.

Using ComponentStore in a Component

@Component({
  selector: 'app-photos',
  template: `
    <input [ngModel]="searchTerm$ | async" (ngModelChange)="setSearchTerm($event)">
    <p>Total Photos: {{ (filteredPhotos$ | async)?.length }}</p>
    <ul>
      <li *ngFor="let photo of filteredPhotos$ | async">{{ photo.id }}</li>
    </ul>
    <button (click)="loadMore()">Load More</button>
  `,
  providers: [PhotosComponentStore]
})
export class PhotosComponent {
  searchTerm$ = this.photosStore.searchTerm$;
  filteredPhotos$ = this.photosStore.filteredPhotos$;
  private readonly photosStore = inject(PhotosComponentStore);

  setSearchTerm(term: string) {
    this.photosStore.setSearchTerm(term);
  }

  loadMore() {
    this.photosStore.loadMorePhotos(10);
  }
}
Enter fullscreen mode Exit fullscreen mode

When to Use ComponentStore Effects vs NgRx Effects

  • Use ComponentStore effects when:
  • The state and side effects are specific to a single component or a small feature.
  • You want to optimize performance by using a lighter-weight solution.
  • You need more granular control over the lifecycle of the effects.
  • Stick with NgRx effects when:
  • The state or side effects are shared across multiple components or the entire application.
  • You need to respond to actions dispatched from various parts of your application.
  • You want to maintain a single source of truth for your entire application state.

Combining ComponentStore with NgRx

In larger applications, you can use both ComponentStore and NgRx together. For example:

  • Use NgRx for global state (user authentication, app-wide settings, etc.)
  • Use ComponentStore for component-specific or feature-specific state (like our photos list)

This approach allows you to benefit from the global state management of NgRx while keeping component-specific logic encapsulated and performant with ComponentStore.

NgRx Effects provide a powerful way to manage side effects in your Angular applications. By centralizing your side effect logic, you can create more maintainable and testable code. Remember to leverage the lifecycle hooks, use appropriate metadata, and thoroughly test your effects to ensure robust application behavior.

This guide covered the key aspects of NgRx Effects, including implementation, lifecycle, testing, and best practices. By following these guidelines and using the provided examples, you can effectively implement and manage effects in your NgRx-powered Angular applications.

Here you can find the code of this example 💻:

https://github.com/amosISA/angular-state-management

And here you can find a free course about NgRx from beggining to master:

If you want to learn more about state management, I have other videos related:

Thanks for reading so far 🙏

I’d like to have your feedback so please leave a comment , clap or follow. 👏

Spread the Angular love! 💜

If you really liked it, share it among your community, tech bros and whoever you want! 🚀👥

Don’t forget to follow me and stay updated: 📱

Thanks for being part of this Angular journey! 👋😁

Originally published at https://www.codigotipado.com.

Comments 0 total

    Add comment