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,
};
}))
);
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))
);
The musiciansReducer now becomes significantly simpler:
export const musiciansReducer = createReducer(
on(musiciansPageActions.search, (state, { query }) => ({
...state,
query,
}))
);
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) {}
}
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,
})
);
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) {}
}
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:
- When a component initializes, an action is dispatched to load the entity collection.
- An effect listens for this action, fetches entities from the API, and emits a new action carrying the loaded entities as its payload.
- A case reducer is set up to catch the action produced by the effect and to incorporate the loaded entities into the state.
- 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' });
}
}
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' });
}
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' });
}
"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
With a clear list of well-named actions, it's easy to follow the sequence of events in the app:
- Login form submission was initiated by the user.
- Auth API confirmed a successful login.
- User navigated to the songs page.
- Songs arrived successfully from the Song API.
- 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
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 }>()
);
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();
}
}
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 }))
)
);
})
);
});
The component then becomes much cleaner:
@Component(/* ... */)
export class SongsComponent implements OnInit {
constructor(private readonly store: Store) {}
ngOnInit(): void {
this.store.dispatch(songsPageActions.opened());
}
}
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 })
)
);
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 }
)
);
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 }
)
);
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-storefor 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 }
);
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 }
);
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 }))
)
);
})
);
});
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 }))
);
})
);
}
}
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 }))
)
);
})
);
});
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(
/* ... */
);
})
);
});
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(
/* ... */
);
})
);
});
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 }));
})
);
})
);
});
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 }
);
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(/* ... */)
);
})
);
});
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(/* ... */)
);
})
);
});
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,
}))
);
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
- Good Action Hygiene with NgRx by Mike Ryan
- Rethinking State in Angular Applications by Alex Okrushko
- Building Sub States with NgRx Selectors by Brandon Roberts
- Maximizing and Simplifying Component Views with NgRx Selectors by Brandon Roberts
- Solving Angular Slow Renders with NgRx Selectors by Tim Deschryver
- Start Using NgRx Effects for This by Tim Deschryver
Acknowledgments
I'm grateful to Brandon, Tim, and Alex for their insightful feedback and suggestions that helped refine this piece.
