32
loading...
This website collects cookies to deliver better user experience
@ngrx/store
and @ngrx/effects
libraries. The list is based on the usual NgRx mistakes I've seen many times (some of which I've made myself) and on the great talks and articles that you can find in the resources section.@ngrx/component-store
. It has many powerful features and fits perfectly with the global NgRx store.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,
};
}))
);
filteredMusicians
is derived from the query
and musicians
array. If you decide to keep the derived value in the store, then you should update it every time one of the values from which it is derived changes. The state will be larger, the reducer will contain additional logic, and you can easily forget to add filtering logic in another reducer that updates query
or musicians
.export const selectFilteredMusicians = createSelector(
selectAllMusicians,
selectMusicianQuery,
(musicians, query) =>
musicians.filter(({ name }) => name.includes(query))
);
musiciansReducer
will now be much simpler:export const musiciansReducer = createReducer(
on(musiciansPageActions.search, (state, { query }) => ({
...state,
query,
}))
);
@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) {}
}
export const selectMusiciansPageViewModel = createSelector(
selectFilteredMusicians,
selectMusiciansQuery,
selectActiveMusician,
(musicians, query, activeMusician) => ({
musicians,
query,
activeMusician,
})
);
@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) {}
}
@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' });
}
}
ngOnInit
method of SongsComponent
will look like this:ngOnInit(): void {
this.store.dispatch({ type: '[Songs] Load Songs' });
this.store.dispatch({ type: '[Composers] Load Composers' });
}
loadSongs$
and loadComposers$
effects:ngOnInit(): void {
this.store.dispatch({ type: '[Songs Page] Opened' });
}
[Login Page] Login Form Submitted
[Auth API] User Logged in Successfully
[Songs Page] Opened
[Songs API] Songs Loaded Successfully
[Composers API] Composers Loaded Successfully
[Auth] Login
[Auth] Login Success
[Songs] Load Songs
[Composers] Load Composers
[Songs] Load Songs Success
[Composers] Load Composers Success
// 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 }>()
);
@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();
}
}
loadSongs
action is dispatched if the songs have not already been loaded. However, there is a better way to achieve the same result but to keep the component clean. We can move this condition to 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 }))
)
);
})
);
});
@Component(/* ... */)
export class SongsComponent implements OnInit {
constructor(private readonly store: Store) {}
ngOnInit(): void {
this.store.dispatch(songsPageActions.opened());
}
}
export const composersReducer = createReducer(
initialState,
// case reducer can listen to multiple actions
on(
composerExistsGuardActions.canActivate,
composersPageActions.opened,
songsPageActions.opened,
(state) => ({ ...state, isLoading: true })
)
);
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 }
)
);
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 }
)
);
@ngrx/component-store
for the global state as well).// the name of the effect is the same as the action it listens to
readonly addComposerSuccess$ = createEffect(
() => {
return this.actions$.pipe(
ofType(composersApiActions.composerAddedSuccess),
tap(() => this.alert.success('Composer saved successfully!'))
);
},
{ dispatch: false }
);
showSaveComposerSuccessAlert
), the previously mentioned drawbacks will be solved.composerUpdatedSuccess
action to the ofType
operator, without having to change the effect name:// 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 }
);
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 }))
)
);
})
);
});
@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 }))
);
})
);
}
}
loadMusician$
effect, but also from other parts of the application. The loadMusician$
effect now looks much 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 }))
)
);
})
);
});
// this effect returns the `loadMusicians` action
// when current page or page size is changed
readonly invokeLoadMusicians$ = createEffect(() => {
return this.actions$.pipe(
ofType(
musiciansPageActions.changeCurrentPage,
musiciansPageActions.changePageSize
),
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(
/* ... */
);
})
);
});
ofType
operator can accept 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.changeCurrentPage,
musiciansPageActions.changePageSize
),
concatLatestFrom(() =>
this.store.select(selectMusiciansPagination)
),
switchMap(([, pagination]) => {
return this.musiciansService.getMusicians(pagination).pipe(
/* ... */
);
})
);
});
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 }));
})
);
})
);
});
// 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.deleteSongFailure),
tap(({ message }) => this.alert.error(message))
);
},
{ dispatch: false }
);
showErrorAlert$
effect for any action that requires an error alert to be shown.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(/* ... */)
);
})
);
});
loadAlbum$
effect will look 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(/* ... */)
);
})
);
});
albumLoadedSuccess
action can be handled by the reducer(s) and/or other effects. In this example, it will be handled by 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,
}))
);