Cover photo by Léonard Cotte on Unsplash.

This article gathers practical guidance for working with @ngrx/store and @ngrx/effects. The recommendations stem from recurring NgRx pitfalls observed in many codebases, including a few I've hit myself, plus insights from excellent conference talks and written pieces listed in the resources section.

Contents

Store Tips

Keep global state centralized

It's best to centralize your application's global state within the NgRx store. When state is scattered across multiple stateful services, maintaining the app becomes increasingly difficult. A common side effect is that these services end up "caching" derived data, which obscures where the true source of truth actually resides.

That said, if you're migrating an existing app to NgRx, it's acceptable to keep legacy stateful services around as an interim measure.


Keep local state out of the global store

Local state is intrinsically linked to a component's lifecycle. It gets initialized, managed, and ultimately cleaned up as the component is created and destroyed.

Storing local state directly in the component and handling it imperatively is perfectly valid. However, if you're already leveraging a reactive global state solution like NgRx store, it's worth considering a reactive approach for local state handling too, such as @ngrx/component-store. It offers a robust feature set and integrates smoothly with the global NgRx store.


Use selectors for derived state

Don't store derived state; compute it with selectors.

First, consider a reducer that includes derived data in its state:

export const musiciansReducer = createReducer(
  on(musiciansPageActions.search, (state, { query }) => {
    // `filteredMusicians` is derived from `musicians` and `query`
    const filteredMusicians = state.musicians.filter(({ name }) =>
      name.includes(query)
    );

    return {
      ...state,
      query,
      filteredMusicians,
    };
  }))
);
Enter fullscreen mode Exit fullscreen mode

The filteredMusicians value is determined by both the query and the musicians array. If this derived value is kept in the store, it needs to be updated every time either of its contributing values changes. This results in a larger state, extra logic in the reducer, and a higher risk of inadvertently forgetting to apply the filtering logic when other reducers modify query or musicians.

Derived state should be handled with selectors. The selector for filtered musicians appears as follows:

export const selectFilteredMusicians = createSelector(
  selectAllMusicians,
  selectMusicianQuery,
  (musicians, query) =>
    musicians.filter(({ name }) => name.includes(query))
);
Enter fullscreen mode Exit fullscreen mode

The musiciansReducer now becomes significantly simpler:

export const musiciansReducer = createReducer(
  on(musiciansPageActions.search, (state, { query }) => ({
    ...state,
    query,
  }))
);
Enter fullscreen mode Exit fullscreen mode

Leverage view model selectors

A view model selector combines other selectors to provide all the state required for a specific view. This offers a great way to streamline container components, as you only need one selector per container. Additionally, view model selectors come with several other benefits.

Let's see what a container component looks like in the absence of a view model selector:

@Component({
  // the value of each Observable is unwrapped via `async` pipe
  template: `
    <musician-search [query]="query$ | async"></musician-search>

    <musician-list
      [musicians]="musicians$ | async"
      [activeMusician]="activeMusician$ | async"
    ></musician-list>

    <musician-details
      [musician]="activeMusician$ | async"
    ></musician-details>
  `,
})
export class MusiciansComponent {
  // select all state chunks required for the musicians container
  readonly musicians$ = this.store.select(selectFilteredMusicians);
  readonly query$ = this.store.select(selectMusiciansQuery);
  readonly activeMusician$ = this.store.select(selectActiveMusician);

  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

This method has several downsides:

  • The container component grows with each additional piece of state it requires.
  • Testing becomes more cumbersome with numerous selectors to mock.
  • The template needs multiple subscriptions.

Now, let's create a view model selector for this container:

export const selectMusiciansPageViewModel = createSelector(
  selectFilteredMusicians,
  selectMusiciansQuery,
  selectActiveMusician,
  (musicians, query, activeMusician) => ({
    musicians,
    query,
    activeMusician,
  })
);
Enter fullscreen mode Exit fullscreen mode

And here's the container after the change:

@Component({
  // single subscription in the template via `async` pipe
  // access to the view model properties via `vm` alias
  template: `
    <ng-container *ngIf="vm$ | async as vm">
      <musician-search [query]="vm.query"></musician-search>

      <musician-list
        [musicians]="vm.musicians"
        [activeMusician]="vm.activeMusician"
      ></musician-list>

      <musician-details
        [musician]="vm.activeMusician"
      ></musician-details>
    </ng-container>
  `,
})
export class MusiciansComponent {
  // select the view model
  readonly vm$ = this.store.select(selectMusiciansPageViewModel);

  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

The component is now more compact and easier to test. The template also only needs a single subscription.


Treat actions as unique events

View NgRx actions as unique events, not as commands, and never reuse them.

Commands might work well for straightforward or isolated features. But for complex functionality that draws on multiple feature states, they can introduce messy code and potential performance problems. Let's go through an example to see why action hygiene — treating actions as unique events — is so important.

Consider a typical NgRx flow for pages that present a list of entities:

  1. When a component initializes, an action is dispatched to load the entity collection.
  2. An effect listens for this action, fetches entities from the API, and emits a new action carrying the loaded entities as its payload.
  3. A case reducer is set up to catch the action produced by the effect and to incorporate the loaded entities into the state.
  4. Finally, the entities are selected from the store and shown in the template:
@Component(/* ... */)
export class SongsComponent implements OnInit {
  // select songs from the store
  readonly songs$ = this.store.select(selectSongs);

  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    // dispatch the `loadSongs` action on component initialization
    this.store.dispatch({ type: '[Songs] Load Songs' });
  }
}
Enter fullscreen mode Exit fullscreen mode

And this pattern works well. No changes are needed initially. But what if you need to load another collection that this particular container component also requires? In this scenario, imagine needing to display the composer for each song that is loaded. If actions are treated as commands, then the ngOnInit method of SongsComponent would look like this:

ngOnInit(): void {
  this.store.dispatch({ type: '[Songs] Load Songs' });
  this.store.dispatch({ type: '[Composers] Load Composers' });
}
Enter fullscreen mode Exit fullscreen mode

This leads us to another crucial principle: Do not dispatch multiple actions sequentially. Dispatching actions one after another can result in unintended intermediate states and trigger pointless event loop cycles.

A much better approach is to dispatch a single action that signals the user's navigation to the songs page. Both the loadSongs$ and loadComposers$ effects can then listen for this one action:

ngOnInit(): void {
  this.store.dispatch({ type: '[Songs Page] Opened' });
}
Enter fullscreen mode Exit fullscreen mode

"Songs Page" represents the source of this action (it originates from the songs page), and "Opened" represents the event itself (the songs page gets opened).

This brings up another guideline: Adopt the "[Source] Event" naming convention and be consistent. It's also beneficial to make action names descriptive. This practice greatly aids in application maintenance and makes bug detection much easier.

If we look at the Redux DevTools for this example with unique event actions, we'll see something like this:

[Login Page] Login Form Submitted
[Auth API] User Logged in Successfully
[Songs Page] Opened
[Songs API] Songs Loaded Successfully
[Composers API] Composers Loaded Successfully
Enter fullscreen mode Exit fullscreen mode

With a clear list of well-named actions, it's easy to follow the sequence of events in the app:

  1. Login form submission was initiated by the user.
  2. Auth API confirmed a successful login.
  3. User navigated to the songs page.
  4. Songs arrived successfully from the Song API.
  5. Composers arrived successfully from the Composers API.

Unfortunately, this isn't possible with command-style actions:

[Auth] Login
[Auth] Login Success
[Songs] Load Songs
[Composers] Load Composers
[Songs] Load Songs Success
[Composers] Load Composers Success
Enter fullscreen mode Exit fullscreen mode

Commands can be triggered from many places, so their source remains unknown.


Group actions by source

The earlier example demonstrated that one action can lead to changes across multiple feature states. Thus, it's better to organize actions by their source rather than by the feature state they affect.

Create a dedicated action file for each source. Here are some examples of action files organized by source:

// songs-page.actions.ts
export const opened = createAction('[Songs Page] Opened');
export const searchSongs = createAction(
  '[Songs Page] Search Songs Button Clicked',
  props<{ query: string }>()
);
export const addComposer = createAction(
  '[Songs Page] Add Composer Form Submitted',
  props<{ composer: Composer }>()
);

// songs-api.actions.ts
export const songsLoadedSuccess = createAction(
  '[Songs API] Songs Loaded Successfully',
  props<{ songs: Song[] }>()
);
export const songsLoadedFailure = createAction(
  '[Songs API] Failed to Load Songs',
  props<{ errorMsg: string }>()
);

// composers-api.actions.ts
export const composerAddedSuccess = createAction(
  '[Composers API] Composer Added Successfully',
  props<{ composer: Composer }>()
);
export const composerAddedFailure = createAction(
  '[Composers API] Failed to Add Composer',
  props<{ errorMsg: string }>()
);

// composer-exists-guard.actions.ts
export const canActivate = createAction(
  '[Composer Exists Guard] Can Activate Entered',
  props<{ composerId: string }>()
);
Enter fullscreen mode Exit fullscreen mode

Avoid conditional action dispatch

Don't dispatch actions conditionally based on the present state. Instead, relocate the condition to the effect or reducer. This tip also promotes good action hygiene.

First, look at a case where an action is dispatched based on a state value:

@Component(/* ... */)
export class SongsComponent implements OnInit {
  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    this.store.select(selectSongs).pipe(
      tap((songs) => {
        // if the songs are not loaded
        if (!songs) {
          // then dispatch the `loadSongs` action
          this.store.dispatch(songsActions.loadSongs());
        }
      }),
      take(1)
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the loadSongs action is dispatched when songs aren't already in the store. However, the same effect can be achieved differently, while keeping the component clean. The condition can be moved into the effect:

readonly loadSongsIfNotLoaded$ = createEffect(() => {
  return this.actions$.pipe(
    // when the songs page is opened
    ofType(songsPageActions.opened),
    // then select songs from the store
    concatLatestFrom(() => this.store.select(selectSongs)),
    // and check if the songs are loaded
    filter(([, songs]) => !songs),
    // if not, load songs from the API
    exhaustMap(() => {
      return this.songsService.getSongs().pipe(
        map((songs) => songsApiActions.songsLoadedSuccess({ songs })),
        catchError((error: { message: string }) =>
          of(songsApiActions.songsLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

The component then becomes much cleaner:

@Component(/* ... */)
export class SongsComponent implements OnInit {
  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    this.store.dispatch(songsPageActions.opened());
  }
}
Enter fullscreen mode Exit fullscreen mode

Create reusable reducers

Use a single case reducer when multiple actions require the same state modification:

export const composersReducer = createReducer(
  initialState,
  // case reducer can listen to multiple actions
  on(
    composerExistsGuardActions.canActivate,
    composersPageActions.opened,
    songsPageActions.opened,
    (state) => ({ ...state, isLoading: true })
  )
);
Enter fullscreen mode Exit fullscreen mode

However, if any of these actions needs to trigger a different state change, don't add special logic to the existing case reducer like this:

export const composersReducer = createReducer(
  initialState,
  on(
    composerExistsGuardActions.canActivate,
    composersPageActions.opened,
    songsPageActions.opened,
    (state, action) =>
      // `composerExistsGuardActions.canActivate` action requires
      // different state change
      action.type === composerExistsGuardActions.canActivate.type &&
      state.entities[action.composerId]
        ? state
        : { ...state, isLoading: true }
  )
);
Enter fullscreen mode Exit fullscreen mode

Instead, set up a distinct case reducer:

export const composersReducer = createReducer(
  initialState,
  on(
    composersPageActions.opened,
    songsPageActions.opened,
    (state) => ({ ...state, isLoading: true })
  ),
  // `composerExistsGuardActions.canActivate` action is moved
  // to a new case reducer
  on(
    composerExistsGuardActions.canActivate,
    (state, { composerId }) =>
      state.entities[composerId]
        ? state
        : { ...state, isLoading: true }
  )
);
Enter fullscreen mode Exit fullscreen mode

Use facades cautiously

I previously used facades to wrap the NgRx store, but have since stopped for a few reasons:

  • If the Redux pattern doesn't suit your needs and you find yourself needing to wrap it in services, consider service-based state management libraries such as Akita or NGXS instead (or use @ngrx/component-store for your global state too).
  • Facades provide little value when you're already using view model selectors and practicing good action hygiene. They just add an extra layer to test and maintain without offering any real advantages.
  • Unless strict rules are enforced in your coding standards, facades can be easily misused (for instance, to trigger side effects).

However, if a container component has its own local state while also using the global store, using the ComponentStore as a dedicated facade for that container is a sensible approach. In this setup, the ComponentStore handles the local state while also selecting global state slices and/or dispatching actions to the global store.


Effects Tips

Name effects like functions

Choose effect names that describe what they accomplish, rather than the action they respond to.

When an effect is named after the action it listens for, the code tends to look like this:

// the name of the effect is the same as the action it listens to
readonly composerAddedSuccess$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(composersApiActions.composerAddedSuccess),
      tap(() => this.alert.success('Composer saved successfully!'))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

This approach has at least two problems. To begin with, the name gives no indication of what the effect actually does. Furthermore, it violates the open-closed principle—if another action should trigger the same side effect, the effect name would need to be changed. In contrast, naming the effect as a function (like showSaveComposerSuccessAlert) resolves both issues.

For instance, when the same success alert needs to appear after a composer is updated, simply pass the composerUpdatedSuccess action into the ofType operator—the effect name remains unchanged:

// the effect name describes what the effect does
readonly showSaveComposerSuccessAlert$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(
        composersApiActions.composerAddedSuccess,
        // new action is added here
        // the rest of the effect remains the same
        composersApiActions.composerUpdatedSuccess
      ),
      tap(() => this.alert.success('Composer saved successfully!'))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

Keep effects simple

Sometimes performing a side effect requires several API calls, or the API returns data in a shape that needs transformation. Yet, placing all of that logic directly in the NgRx effect often results in code that is difficult to read.

Consider an effect that needs two separate API requests to gather the complete data set:

readonly loadMusician$ = createEffect(() => {
  return this.actions$.pipe(
    // when the musician details page is opened
    ofType(musicianDetailsPage.opened),
    // then select musician id from the route
    concatLatestFrom(() =>
      this.store.select(selectMusicianIdFromRoute)
    ),
    concatMap(([, musicianId]) => {
      // and load musician from the API
      return this.musiciansResource.getMusician(musicianId).pipe(
        // wait for musician to load
        mergeMap((musician) => {
          // then load band from the API
          return this.bandsResource.getBand(musician.bandId).pipe(
            // append band name to the musician
            map((band) => ({ ...musician, bandName: band.name }))
          );
        }),
        // if the musician is successfully loaded
        // then return success action and pass musician as a payload
        map((musician) =>
          musiciansApiActions.musicianLoadedSuccess({ musician })
        ),
        // if an error occurs, then return error action
        catchError((error: { message: string }) =>
          of(musiciansApiActions.musicianLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

This effect is both long and hard to follow, even with explanatory comments. The solution is to move the API calls into a service, making the effect leaner and clearer. The service method for fetching a musician might look like this:

@Injectable()
export class MusiciansService {
  getMusician(musicianId: string): Observable<Musician> {
    return this.musiciansResource.getMusician(musicianId).pipe(
      mergeMap((musician) => {
        return this.bandsResource.getBand(musician.bandId).pipe(
          map((band) => ({ ...musician, bandName: band.name }))
        );
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This service method can be used by the loadMusician$ effect and also from elsewhere in the application. The loadMusician$ effect itself becomes far more readable:

readonly loadMusician$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(musicianDetailsPage.opened),
    concatLatestFrom(() =>
      this.store.select(selectMusicianIdFromRoute)
    ),
    concatMap(([, musicianId]) => {
      // API calls are moved to the `getMusician` method
      return this.musiciansService.getMusician(musicianId).pipe(
        map((musician) =>
          musiciansApiActions.musicianLoadedSuccess({ musician })
        ),
        catchError((error: { message: string }) =>
          of(musiciansApiActions.musicianLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

If you deal with legacy APIs, you're probably familiar with responses that don't fit your app's expected format, requiring conversion. Apply the same principle here: encapsulate the API call and any mapping logic in a service method, and then let the effect call that method.


Don't create "boiler" effects

Avoid creating effects that exist solely to convert multiple related actions into one action:

// this effect returns the `loadMusicians` action
// when current page or page size is changed
readonly invokeLoadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(
      musiciansPageActions.currentPageChanged,
      musiciansPageActions.pageSizeChanged
    ),
    map(() => musiciansActions.loadMusicians())
  );
});

// this effect loads musicians from the API
// when the `loadMusicians` action is dispatched
readonly loadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(musiciansAction.loadMusicians),
    concatLatestFrom(() =>
      this.store.select(selectMusiciansPagination)
    ),
    switchMap(([, pagination]) => {
      return this.musiciansService.getMusicians(pagination).pipe(
        /* ... */
      );
    }) 
  );
});
Enter fullscreen mode Exit fullscreen mode

This is unnecessary because the ofType operator allows you to pass a sequence of actions:

readonly loadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    // `ofType` accepts a sequence of actions
    // and there is no need for "boiler" effects (and actions)
    ofType(
      musiciansPageActions.currentPageChanged,
      musiciansPageActions.pageSizeChanged
    ),
    concatLatestFrom(() =>
      this.store.select(selectMusiciansPagination)
    ),
    switchMap(([, pagination]) => {
      return this.musiciansService.getMusicians(pagination).pipe(
        /* ... */
      );
    }) 
  );
});
Enter fullscreen mode Exit fullscreen mode

Apply single responsibility principle

Essentially, avoid performing multiple side effects in a single NgRx effect. Effects that have one specific job are clearer and simpler to maintain.

Look at this effect, which handles two different side effects:

readonly deleteSong$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(songsPageActions.deleteSong),
    concatMap(({ songId }) => {
      // side effect 1: delete the song
      return this.songsService.deleteSong(songId).pipe(
        map(() => songsApiActions.songDeletedSuccess({ songId })),
        catchError(({ message }: { message: string }) => {
          // side effect 2: display an error alert in case of failure
          this.alert.error(message);
          return of(songsApiActions.songDeletedFailure({ message }));
        })
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

By applying the single responsibility principle, this is split into two separate NgRx effects:

// effect 1: delete the song
readonly deleteSong$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(songsPageActions.deleteSong),
    concatMap(({ songId }) => {
      return this.songsService.deleteSong(songId).pipe(
        map(() => songsApiActions.songDeletedSuccess({ songId })),
        catchError(({ message }: { message: string }) =>
          of(songsApiActions.songDeletedFailure({ message }))
        )
      );
    })
  );
});

// effect 2: show an error alert
readonly showErrorAlert$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(songsApiActions.songDeletedFailure),
      tap(({ message }) => this.alert.error(message))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

There is an additional benefit to this: Effects with single responsibility are reusable. For example, the showErrorAlert$ effect can be used for any action that needs to trigger an error alert.


Apply good action hygiene

The principles for actions dispatched via the store also extend to effects:

  • Don't return an array of actions (commands) from the effect.
  • Return a unique action that can be handled by multiple reducers and/or effects.

Let's look at an example first where multiple actions are emitted from a single effect:

readonly loadAlbum$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(albumsActions.loadCurrentAlbum),
    concatLatestFrom(() => this.store.select(selectAlbumIdFromRoute)),
    concatMap(([, albumId]) => {
      return this.albumsService.getAlbum(albumId).pipe(
        // an array of actions is returned on successful load
        // then, `loadSongsSuccess` is handled by `songsReducer`
        // and `loadComposersSuccess` is handled by `composersReducer`
        mergeMap(({ songs, composers }) => [
          songsActions.loadSongsSuccess({ songs }),
          composersActions.loadComposersSuccess({ composers }),
        ]),
        catchError(/* ... */)
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

I've seen effects like this many times. It usually happens when actions are treated as commands. The downsides of this pattern are covered in the Treat actions as unique events section.

With good action hygiene, however, the loadAlbum$ effect will be like this:

readonly loadAlbum$ = createEffect(() => {
  return this.actions$.pipe(
    // when the album details page is opened
    ofType(albumDetailsPageActions.opened),
    // then select album id from the route
    concatLatestFrom(() => this.store.select(selectAlbumIdFromRoute)),
    concatMap(([, albumId]) => {
      // and load current album from the API
      return this.albumsService.getAlbum(albumId).pipe(
        // return unique action when album is loaded successfully
        map(({ songs, composers }) =>
          albumsApiActions.albumLoadedSuccess({ songs, composers })
        ),
        catchError(/* ... */)
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Now, the albumLoadedSuccess action can be processed by the reducer(s) and/or other effects. In this case, it will be handled by both songsReducer and composersReducer:

// songs.reducer.ts
export const songsReducer = createReducer(
  on(albumsApiActions.albumLoadedSuccess, (state, { songs }) => ({
    ...state,
    songs,
  }))
);

// composers.reducer.ts
export const composersReducer = createReducer(
  on(albumsApiActions.albumLoadedSuccess, (state, { composers }) => ({
    ...state,
    composers,
  }))
);
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

NgRx offers a great deal of flexibility, allowing you to achieve the same outcome through a variety of approaches. Over time, however, certain patterns have crystallized into recommended practices. Adopting these in your own projects can significantly boost code quality, performance, and long-term maintainability.

Further Reading

Acknowledgments

I'm grateful to Brandon, Tim, and Alex for their insightful feedback and suggestions that helped refine this piece.