Understanding NGRX - Final Part
This is the final part of a sequence of articles on NGRX. Having said that, if you happen to come across this one first, I highly recommend that you read the first part to get a better understanding of the things I will cover in this one.
You can find the said article here:
However, if you feel like you already understand the basic concepts that define the way the library works, feel free to skip the first part.
Now that we've gotten that out of the way, let's see the things I'll show you in this article:
Alright, let's get started!
Installing NGRX
Before installing the library, create a new project using the Angular CLI:
ng new my-project --skip-tests --skip-git --inline-template --inline-style --standalone
This command will set up an angular project named "my-project" for you. There are a few variables that I intentionally used to get unnecessary things out of the way. Let's see what they do.
Cool, now that we've successfully generated our project, let's intall NGRX.
1. Installing the Store Package
The first thing we're gonna do is install the package that provides the NGRX Store. All we need to do is run the following command:
ng add @ngrx/store@latest
If you're using NPM:
npm install @ngrx/store --save
If you're using Yarn:
yarn add @ngrx/store
Alright, now the next thing we need to do is install the package that allows us to work with effects.
Installing the Effects Package
In order to install the Effects Package, all we need to do is run the following command:
ng add @ngrx/effects@latest
If you're using NPM:
npm install @ngrx/effects --save
If you're using Yarn:
yarn add @ngrx/effects
Cool, now, the next thing we need, is the store devtools package.
Installing the Store Devtools Package
NGRX offers this package so we can integrate de Redux Devtools extension with the project, which is extremely helpful.
To install the package, just run the following command:
ng add @ngrx/store-devtools@latest
If you're using NPM:
npm install @ngrx/store-devtools --save
If you're using Yarn:
yarn add @ngrx/store-devtools
Alright, now we have everything we need to start using NGRX.
2. Setting up the project to work with NGRX
Now that we have the packages we need successfully installed, we need to do a few things to get the project working with NGRX.
2.1 Setting up the Store
Go to the main.ts file and import the provideStore from @ngrx/store.
import { provideStore } from '@ngrx/store';
Now, call the provideStore function inside your providers array:
bootstrapApplication(AppComponent, {
providers: [ provideStore() ]
})
This is gonna create an empty Store, meaning, an empty JavaScript object that will serve as the global state for our application.
2.2 Setting up the store devtools
To set up the store devtools, also in the main.ts file, import the provideStoreDevtools function from @ngrx/store-devtools and the isDevMode from @angular/core.
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { isDevMode } from '@angular/core';
Then, call the provideStoreDevtools function inside the providers array:
bootstrapApplication(AppComponent, {
providers: [ provideStoreDevTools({ logOnly: !isDevMode() }) ]
})
The providerStoreDevTools takes an object as a parameter. Such object allows you to change some of the configuration that changes the way the extension works. In our case, we're using the logOnly property and telling the extension to only log when we're not in development mode.
Alright, now we're ready to start writing our actions, reducers, effects and selectors.
3. Creating Actions
Before we start writing our actions, let's set up a structure for our store.
Create a state folder inside of the app folder, like this:
_src
_app
_state
Inside the state folder, we'll create folders that relate to what we call feature modules. Feature modules in NGRX are represented by specific slices of the global state. Basically, every single property of the Store represents a feature module.
For this article, we're gonna focus on only one module called albums. So, inside the state folder, let's create a folder called albums. You should end up with a structure that looks like this:
_src
_app
_state
_albums
Inside of the albums folder, create one file called albums.actions.ts, albums.effects.ts, albums.selectors.ts and albums.reducers.ts. Your file structure should look like this:
_src
_app
_state
_albums
albums.actions.ts
albums.effects.ts
albums.selectors.ts
albums.reducers.ts
Now, inside the app folder, create a folder called interfaces and inside of it, create a file called album.ts.
_app
_interfaces
album.ts
Open the album.ts file and write the following:
export interface Album {
id: string;
title: string;
cover: string;
description: string;
releaseDate: string;
band: string;
tracks: string[];
}
This is our TypeScript Interface that represents the shape of our Album data.
Alright, now, open the album.actions.ts so we can start writing our actions.
The first thing we need to do is import the createActionGroup, emptyProps and props from @ngrx/store.
import { createActionGroup, emptyProps, props } from '@ngrx/store';
Now, let's call the createActionGroup to create a group of actions and assign it to an exportable variable called AlbumActions.
export const AlbumsActions = createActionGroup(
? source: '[ALBUMS]',
? events: {
? ? 'Load Albums': emptyProps(),
? ? 'Add Album': props<{ album: Album }>(),
? ? 'Remove Album': props<{ albumId: string }>(),
? },
});
The createActionGroup takes an object that defines the source of the dispatched actions, meaning, where an action that belongs to this group of actions has been dispatched, and an events object that defines every single action that belongs to this group.
The emptyProps() function is called to make sure the Load Albums action doesn't return any props. As for the Add Album and Remove Album, they both return props, which is an object that represent data we want to pass to our reducers.
Alright, now let's create a second group of actions, and this one is gonna be for actions that will be handled by our effects.
export const AlbumsActionsAPI = createActionGroup(
source: '[ALBUMS API]',
events: {
'Albums successfully fetched': props<{ albums: Album[] }>(),
'Error Fetching Albums': props<{ error: string }>(),
},
});
As you can see, the source for this group of actions is called [ ALBUMS API ]. The reason why is that "Albums successfully fetched" and "Error Fetching Albums" are both called by our effect since they're actions that cause what we call side-effects.
As we've seen in the first article of this series, fetching data from API's causes side-effects, because it's an asynchronous operation and we don't know exactly when the fetched data will be returned.
Cool, now that we have our actions ready, let's create our reducers.
4. Creating Reducers
Open up the albums.reducers.ts file and impot the following dependencies:
import { createReducer, on } from '@ngrx/store';
import { AlbumsActionsAPI, AlbumsActions } from './albums.actions';
import { Album } from 'src/app/interfaces/album';
As you can see, we're also importing the actions and the interface we've created before.
Now, let's create an exportable variable called featureKey and assign a string to it like this:
export const featureKey = 'albums';
This is gonna be used as a token for our feature state, meaning, it's gonna be the name of the property added to our store, and, as we've learned before, this property is gonna represent the albums module.
Now, let's create an interface that shapes our initial state for the albums:
export interface FeatureInitialState {
albums: Album[];
status: string;
error: string | null;
}
Our feature state is an object with 3 properties, albums, status and error. The albums property is an array of Albums. The status property is a string and the error property can either be a string or null.
Now, let's create our initial state for albums:
export const featureInitialState: FeatureInitialState = {
albums: [],
status: 'pending',
error: null,
};
Our initial state matches the shape defined by our interface and we initialize every single one of its properties with an initial value. The albums property starts off as an empty array, the status property as "pending" and the error property as null.
Now, let's create an exportable variable called albumReducer, and then, call the createReducer function and assign it to the albumReducer variable we've just created:
export const albumsReducer = createReducer
? featureInitialState,
? on(AlbumsActions.loadAlbums, (state) => ({
? ? ...state,
? ? status: 'loading',
? })),
? on(AlbumsActions.addAlbum, (state, { album }) => ({
? ? ...state,
? ? albums: [...state.albums, album],
? })),
? on(AlbumsActions.removeAlbum, (state, { albumId }) => ({
? ? ...state,
? ? albums: state.albums.filter((album) => album.id !== albumId),
? })),
? on(AlbumsActionsAPI.albumsSuccessfullyFetched, (state, { albums }) => ({
? ? ...state,
? ? status: 'success',
? ? albums: [...state.albums, ...albums],
? })),
? on(AlbumsActionsAPI.errorFecthingAlbums, (state, { error }) => ({
? ? ...state,
? ? status: 'pending',
? ? error: error,
? }))
);
We're passing to the createReducer function the initial state we've created, also the returned value from the call of the "on" function. The "on" function listens to an action and updates the state based on the props the action it listens to returns.
The first "on" function listens to the loadAlbums action and then, it creates a new object based on the current state ( immutability ) with the status property set to "loading". The new object is then merged with the current state, which causes it to update.
The second "on" function listens to an action called addAlbum, which creates a new version of the state with a new added album. The new added album was returned as props by the addAlbum action.
The third "on" function listens to an action called removeAlbum, and this action passes an object ( props ) to the reducer that represents the id of a specific album. The said id is used to create a new version of the state that removes the album with an id that matches the id we've got from the props of the action.
The fourth "on" function listens to an action called albumsSuccessfullyFetched, and this one is gonna be dispatched by our effect. Here, the props we get from this action contains a list of albums which is returned by our API ( we're gonna create it later ), then a new object is created where the status property is set to "success" and the albums array is updated to hold the collection of albums returned by the API. After that, the state is updated to reflect these changes.
The last "on" function listens to an action called errorFecthingAlbums, and this one will also be dispatched by our effect. This one has an object with an error property and a new object based on the current state of our feature state ( albums ) is created that sets the status property to "pending" and the error to the the value from the error property we got from the props returned by the action. After that, the state is updated to reflect these changes.
Now that we've finished writing our reducers, let's create the effect that will handle side effects that some of our actions create.
5. Creating Effects
Open up the albums.effects file and import the following dependencies:
import { createEffect, ofType, Actions } from '@ngrx/effects'
import { Injectable } from '@angular/core';
import { AlbumsService } from 'src/app/services/albums.service';
import { AlbumsActions, AlbumsActionsAPI } from './albums.actions';
import { catchError, map, of, switchMap, tap } from 'rxjs';
Here we're importing the dependencies that are specific about effects but also, we're importing the Injectable decorator from @angular/core, the AlbumsService, which we'll create in this section, the actions we need to listen to and some of the RXJS's operators.
Before we continue, let's set up our API and the AlbumsService, so we can continue writing our effect.
5.1 Setting up our API
Here I'm gonna use a package called json-server that allows us to easily create a server that hosts a json file that simulates our API. Having said that, let's intall the json-server package. To do that, run the following command:
npm install -g json-server
The -g flag indicates that we're globally installing the package.
Now that we have the package installed, at the root level of the project, create a file named db.json and write the following:
{
"albums": [
{
"id": "1",
"title": "The White Stripes",
"cover": "https://static.stereogum.com/uploads/2019/06/The-White-Stripes-1560538195-1000x1036.jpg",
"description": "The White Stripes is the debut studio album by American rock duo the White Stripes, released on June 15, 1999. The album was produced by Jim Diamond and vocalist/guitarist Jack White, recorded in January 1999 at Ghetto Recorders and Third Man Studios in Detroit. White dedicated the album to deceased blues musician Son House.",
"releaseDate": "1999",
"band": "The White Stripes",
"tracks": [
"Jimmy The Exploder",
"Stop Breaking Down",
"The Big Three Killed My Baby",
"Suzy Lee",
"Sugar Never Tasted So Good",
"Wasting My Time",
"Cannon",
"Astro",
"Broken Bricks",
"When I Hear My Name",
"Do",
"Screwdriver",
"One More Cup Of Coffee",
"Little People",
"Slicker Drips",
"St. James Infirmary Blues",
"I Fought Piranhas"
]
},
{
"id": "2",
"title": "De Stijl",
"cover": "https://m.media-amazon.com/images/I/7184R3pYGjL._UF1000,1000_QL80_.jpg",
"description": "De Stijl is the second studio album by the American rock duo the White Stripes, released on June 20, 2000, on Sympathy for the Record Industry. The album was recorded following the covert divorce of band members Jack and Meg White, who insisted they continue in music. It was produced by Jack White, and was recorded on an 8-track analog tape in his living room.",
"releaseDate": "2000",
"band": "The White Stripes",
"tracks": [
"You're Pretty Good Looking",
"Hello Operator",
"Little Bird",
"Apple Blossom",
"I'm Bound To Pack It Up",
"Death Letter",
"Sister, Do You Know My Name?",
"Truth Doesn't Make A Noise",
"A Boy's Best Friend",
"Let's Build A Home",
"Jumble, Jumble",
"Why Can't You Be Nicer To Me?",
"Your Southern Can Is Mine"
]
},
{
"id": "3",
"title": "Elephant",
"cover": "https://vinilo.co.uk/cdn/shop/products/The-White-Stripes-Elephant-20th-Anniversary-Edition_1467x.jpg?v=1678462518",
"description": "Elephant is the fourth studio album by the American rock duo The White Stripes. It was released on April 1, 2003, through V2, XL, and Third Man records. The majority of the album was recorded across two weeks in April 2002 and produced without the use of computers, instead utilizing an eight-track tape machine and various gear no more recent than 1963.",
"releaseDate": "2003",
"band": "The White Stripes",
"tracks": [
"Seven Nation Army",
"Black Math",
"There’s No Home for You Here",
"I Just Don’t Know What to Do With Myself",
"In the Cold, Cold Night",
"I Want to Be the Boy to Warm Your Mother’s Heart",
"You’ve Got Her in Your Pocket",
"Ball and Biscuit",
"The Hardest Button to Button",
"Little Acorns",
"Hypnotize",
"The Air Near My Fingers",
"Girl, You Have No Faith in Medicine",
"It’s True That We Love One Another"
]
}
]
}
Yes, I love The White Stripes :P
Anyways, this is the json file that represents our API, and it's gonna be served by the server created by the json-server package. Now, to start the server, run the following command:
json-server --watch db.json
This is the output you will get:
Loading db.json
? Done
? Resources
? https://localhost:3000/albums
? Home
? https://localhost:3000
This means that you now have a server running at port 3000 and the albums can be accessed via the /albums at the end of the URL.
Alright, now that we have our server and API working, let's create a service for our albums.
5.2 The Albums Service
Inside the app folder, create a folder called services and inside of it, create a file called albums.service.ts. Now, in this file, import the following dependencies:
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Album } from '../interfaces/album';
Here, we're importing the HttpClient dependency, the injectable decorator, the Observable factory from RXJS and the album interface.
Now, let's write our service.
领英推荐
@Injectable({ providedIn: 'root' })
export class AlbumsService {
constructor(private http: HttpClient) {}
getAlbums(): Observable<Album[]> {
return this.http.get<Album[]>('https://localhost:3000/albums');
}
}
As you can see, this is a very simple service. We're injecting the HttpClient as a dependency and we're providing this service in a global context through the providedIn property. We have one method called getAlbums that returns an Observable that emits an array of Albums. We're also refering to our API to grab the data we need.
Alright, now that we're done with our service, let's finish writing our effect.
5.3 Finishing up our effect
Back to the albums.effects.ts file, we now need to create an injectable class that represents our effect. Let's do that.
@Injectable(
export class AlbumsEffects {
constructor(
private albumsService: AlbumsService,
private actions$: Actions
) {}
$loadAlbums = createEffect(() =>
this.actions$.pipe(
ofType(AlbumsActions.loadAlbums),
switchMap(() =>
this.albumsService.getAlbums().pipe(
tap((albums) => console.log('tapping api...', albums)),
map((albums) =>
AlbumsActionsAPI.albumsSuccessfullyFetched({ albums })
),
catchError((error) =>
of(AlbumsActionsAPI.errorFecthingAlbums({ error }))
)
)
)
)
);
}
Here we're injecting the albums service we've created and the Actions Stream. The Action Stream emits the actions that are dispatched by the application so, we need it to be able to react to the ones our effect is interested in.
Also, we have a property called loadAlbums, which points to an Observable returned by the createEffect function.
The createEffect function takes a callback that returns an Observable that is the result of the transformation of the value emitted by the Actions Stream. First, we pipe through it and then we call the ofType operator passing the loadAlbums action to it. Then, we call the switchMap operator to switch to a different stream, in this case, the albums service we've created before. Then, we pipe through the emission of the service, since it returns an Observable, and we call the map operator, so we can call the albumsSuccessfullyFetched action and pass to it the albums returned from the service stream. This action is gonna be then handled by our reducer, as we've seen when we've written our reducers.
If an error occurs, we call the catchError operator and we return an Observable that emits the errorFecthingAlbums action call, which is then handled by our reducers as well.
Now, we need to create our selectors.
6. Creating Selectors
Selectors allow us to get specific slices of the Store, meaning, we can get access to specific properties of the store object. As we've seen before, the albums feature state is a property of the global store so, we can use a selector to directly get a reference to it. Let's do that.
Open the albums.selectors.ts and import the following:
import { createSelector, createFeatureSelector } from '@ngrx/store'
import { FeatureInitialState } from './albums.reducers';
Here we're importing the createSelector and the createFeatureSelector from '@ngrx/store'.
Also, we're importing the FeatureInitialState interface.
Now, let's create our selector.
export const selectFeatureState =
createFeatureSelector<FeatureInitialState>('albums');
export const selectAlbums = createSelector(
selectFeatureState,
(state: FeatureInitialState) => state.albums
);
We're calling the createFeatureSelectorFunction to get a reference to the albums property of the Store, which is in the shape of FeatureInitialState;
Then, we call the createSelector function and we pass to it the featureSelector we created and a callback that returns the albums array we want from the state.
7. Piecing everything together
Now that we've managed to successfully create our actions, reducers, effect and selectors, we need to create the albums module and register our featureReducer and effect so they can work as soon as our module is loaded.
Inside of the app folder, create a folder called modules and inside of it, create a folder called albums. Inside the albums folder, create a folder called containers, and inside of it, create a file called albums-home.component.ts.
Inside of the albums folder, create a file called albums.routing.ts. Your file structure should look like this:
_app
_interfaces
_services
_state
_modules
_albums
_containers
albums-home.component.ts
albums.routing.ts
Ok, now, let's create the component that will be our home page for albums. Open the albums-home.component.ts and import the following:
import { Component, OnInit } from '@angular/core'
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Album } from 'src/app/interfaces/album';
import { AlbumsActions } from 'src/app/state/albums/albums.actions';
import { selectAlbums } from 'src/app/state/albums/albums.selectors';
Here we're importing the requirements for creating components such as the Component decorator and the OnInit lifecycle hook.
Also we're importing the Store from @ngrx/store, Observable factory from rxjs, the album interface, the actions we need and the selector we've created.
Now let's write our component.
@Component(
selector: 'albums-home',
template: `
<h1>ALBUMS HOME</h1>
<ng-container *ngIf="albums$ | async as albums; else loading">
<li *ngFor="let album of albums">Album Title: {{ album.title }}</li>
</ng-container>
<ng-template #loading>
<span>Loading...</span>
</ng-template>
`,
imports: [NgIf, NgFor, AsyncPipe],
standalone: true,
})
export class AlbumsHomeComponent implements OnInit {
public albums$: Observable<Album[]> = this.store.select(selectAlbums);
constructor(private store: Store) {}
ngOnInit() {
this.store.dispatch(AlbumsActions.loadAlbums());
}
}
As you can see, our component is very simple and it doesn't have to deal with side effects like fecthing data from API's because that's been delegated to our effect, which makes testing components so much easier.
We're using the ng-container tag that allows us to wrap content in a container that isn't rendered in the DOM. Also, it allows us to use conditional directives like ngIf and ngFor. The contents of the ng-container will only be rendered when the subscription ( using the async pipe ) happens, otherwise, the contents of the ng-template tag, which shows a loding message, will be rendered instead.
The stream we're subscribing to using the async pipe is the album collection returned by our selector stream.
When the ngOnInit lifecycle hook is called, the loadAlbums action is dispatched and our effect handles it as we've seen before.
If everything goes well with the subscription in the template, we get to see a list of album titles.
Now, let's open the albums.routing.ts and create a route for the component we've just created.
import { Route } from '@angular/router'
import { AlbumsHomeComponent } from './containers/albums-home.component';
import { provideState } from '@ngrx/store';
import {
albumsReducer,
featureKey,
} from 'src/app/state/albums/albums.reducers';
import { provideEffects } from '@ngrx/effects';
import { AlbumsEffects } from 'src/app/state/albums/albums.effects';
export const albumsRouting: Route[] = [
{
path: 'home',
component: AlbumsHomeComponent,
providers: [
provideState({ name: featureKey, reducer: albumsReducer }),
provideEffects(AlbumsEffects),
],
},
];
Here we created a route that renders the AlbumsHomeComponent whenever we navigate to albums/home. Also, we're registering our feature state by calling the provideState and passing it to our providers array. We do the same to register our effect, we call the provideEffects inside the providers array while passing our effect to it.
Now, we need to create a route for the albums module in the main.ts file. Let's do that.
const appRoutes: Route[] = [
{
path: 'albums',
loadChildren: () =>
import('./app/modules/albums/albums.routing').then(
(md) => md.albumsRouting
),
},
];
Now, we have a route for /albums.
Then, we need to register the route by calling the provideRouter as well as pass the route we've created to it.
bootstrapApplication(AppComponent,
providers: [
importProvidersFrom(BrowserModule),
provideStore(),
provideHttpClient(),
provideRouter(appRoutes),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
}).catch((err) => console.error(err));
Now, if we run the application and navigate to /albums/home, this is what we'll get:
As you can see, the store devtools shows our state and our actions on the left panel. Also you can see that our component is loading successfully the list of album titles. The first action that's been dispatched was the Load Albums, then the Albums API, which is dispatched by our effect and then handled by our reducers which in turn, updates the Store.
Conclusion
This article shows how to get started with NGRX, as well as how to piece its main concepts together so you can see them in action.
Thank you so much for your interest and for your time.
See you on the next one.