📜 ⬆️ ⬇️

angular-ngrx-data - state management and CRUD in five minutes

image
To date, no large SPA application can do without state management . For Angular in this area there are several solutions. The most popular of these is NgRx . It implements the Redux pattern using the RxJs library and has good tools.

In this article, we will briefly go through the main NgRx modules and focus in more detail on the angular-ngrx-data library, which allows you to make a full -featured CRUD with state management in five minutes.

NgRx Review


Details about NgRx can be found in the following articles:

- Reactive applications on Angular / NGRX. Part 1. Introduction
- Reactive applications on Angular / NGRX. Part 2. Store
- Reactive applications on Angular / NGRX. Part 3. Effects
')
A brief look at the main modules NgRx , its pros and cons.

NgRx / store - implements the Redux pattern.

Simple store implementation
counter.actions.ts
export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT'; export const RESET = 'RESET'; 

counter.reducer.ts

 import { Action } from '@ngrx/store'; const initialState = 0; export function counterReducer(state: number = initialState, action: Action) { switch (action.type) { case INCREMENT: return state + 1; case DECREMENT: return state - 1; case RESET: return 0; default: return state; } } 
.
Connect to module
 import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { counterReducer } from './counter'; @NgModule({ imports: [StoreModule.forRoot({ count: counterReducer })], }) export class AppModule {} 

Use in component
 import { Component } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { Observable } from 'rxjs'; import { INCREMENT, DECREMENT, RESET } from './counter'; interface AppState { count: number; } @Component({ selector: 'app-my-counter', template: ` <button (click)="increment()">Increment</button> <div>Current Count: {{ count$ | async }}</div> <button (click)="decrement()">Decrement</button> <button (click)="reset()">Reset Counter</button> `, }) export class MyCounterComponent { count$: Observable<number>; constructor(private store: Store<AppState>) { this.count$ = store.pipe(select('count')); } increment() { this.store.dispatch({ type: INCREMENT }); } decrement() { this.store.dispatch({ type: DECREMENT }); } reset() { this.store.dispatch({ type: RESET }); } } 


NgRx / store-devtools - allows you to track changes in the application through redux-devtools .

Connection example
 import { StoreDevtoolsModule } from '@ngrx/store-devtools'; @NgModule({ imports: [ StoreModule.forRoot(reducers), //      StoreModule StoreDevtoolsModule.instrument({ maxAge: 25, //   25  }), ], }) export class AppModule {} 


NgRx / effects - allows you to add data to the storage, coming into the application, such as http requests.

Example
./effects/auth.effects.ts
 import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Action } from '@ngrx/store'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, of } from 'rxjs'; import { catchError, map, mergeMap } from 'rxjs/operators'; @Injectable() export class AuthEffects { // Listen for the 'LOGIN' action @Effect() login$: Observable<Action> = this.actions$.pipe( ofType('LOGIN'), mergeMap(action => this.http.post('/auth', action.payload).pipe( // If successful, dispatch success action with result map(data => ({ type: 'LOGIN_SUCCESS', payload: data })), // If request fails, dispatch failed action catchError(() => of({ type: 'LOGIN_FAILED' })) ) ) ); constructor(private http: HttpClient, private actions$: Actions) {} } 

Connect effect to module

 import { EffectsModule } from '@ngrx/effects'; import { AuthEffects } from './effects/auth.effects'; @NgModule({ imports: [EffectsModule.forRoot([AuthEffects])], }) export class AppModule {} 


NgRx / entity - provides the ability to work with data arrays.

Example
user.model.ts

 export interface User { id: string; name: string; } 

user.actions.ts

 import { Action } from '@ngrx/store'; import { Update } from '@ngrx/entity'; import { User } from './user.model'; export enum UserActionTypes { LOAD_USERS = '[User] Load Users', ADD_USER = '[User] Add User', UPSERT_USER = '[User] Upsert User', ADD_USERS = '[User] Add Users', UPSERT_USERS = '[User] Upsert Users', UPDATE_USER = '[User] Update User', UPDATE_USERS = '[User] Update Users', DELETE_USER = '[User] Delete User', DELETE_USERS = '[User] Delete Users', CLEAR_USERS = '[User] Clear Users', } export class LoadUsers implements Action { readonly type = UserActionTypes.LOAD_USERS; constructor(public payload: { users: User[] }) {} } export class AddUser implements Action { readonly type = UserActionTypes.ADD_USER; constructor(public payload: { user: User }) {} } export class UpsertUser implements Action { readonly type = UserActionTypes.UPSERT_USER; constructor(public payload: { user: User }) {} } export class AddUsers implements Action { readonly type = UserActionTypes.ADD_USERS; constructor(public payload: { users: User[] }) {} } export class UpsertUsers implements Action { readonly type = UserActionTypes.UPSERT_USERS; constructor(public payload: { users: User[] }) {} } export class UpdateUser implements Action { readonly type = UserActionTypes.UPDATE_USER; constructor(public payload: { user: Update<User> }) {} } export class UpdateUsers implements Action { readonly type = UserActionTypes.UPDATE_USERS; constructor(public payload: { users: Update<User>[] }) {} } export class DeleteUser implements Action { readonly type = UserActionTypes.DELETE_USER; constructor(public payload: { id: string }) {} } export class DeleteUsers implements Action { readonly type = UserActionTypes.DELETE_USERS; constructor(public payload: { ids: string[] }) {} } export class ClearUsers implements Action { readonly type = UserActionTypes.CLEAR_USERS; } export type UserActionsUnion = | LoadUsers | AddUser | UpsertUser | AddUsers | UpsertUsers | UpdateUser | UpdateUsers | DeleteUser | DeleteUsers | ClearUsers; 

user.reducer.ts
 import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import { User } from './user.model'; import { UserActionsUnion, UserActionTypes } from './user.actions'; export interface State extends EntityState<User> { // additional entities state properties selectedUserId: number | null; } export const adapter: EntityAdapter<User> = createEntityAdapter<User>(); export const initialState: State = adapter.getInitialState({ // additional entity state properties selectedUserId: null, }); export function reducer(state = initialState, action: UserActionsUnion): State { switch (action.type) { case UserActionTypes.ADD_USER: { return adapter.addOne(action.payload.user, state); } case UserActionTypes.UPSERT_USER: { return adapter.upsertOne(action.payload.user, state); } case UserActionTypes.ADD_USERS: { return adapter.addMany(action.payload.users, state); } case UserActionTypes.UPSERT_USERS: { return adapter.upsertMany(action.payload.users, state); } case UserActionTypes.UPDATE_USER: { return adapter.updateOne(action.payload.user, state); } case UserActionTypes.UPDATE_USERS: { return adapter.updateMany(action.payload.users, state); } case UserActionTypes.DELETE_USER: { return adapter.removeOne(action.payload.id, state); } case UserActionTypes.DELETE_USERS: { return adapter.removeMany(action.payload.ids, state); } case UserActionTypes.LOAD_USERS: { return adapter.addAll(action.payload.users, state); } case UserActionTypes.CLEAR_USERS: { return adapter.removeAll({ ...state, selectedUserId: null }); } default: { return state; } } } export const getSelectedUserId = (state: State) => state.selectedUserId; // get the selectors const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors(); // select the array of user ids export const selectUserIds = selectIds; // select the dictionary of user entities export const selectUserEntities = selectEntities; // select the array of users export const selectAllUsers = selectAll; // select the total user count export const selectUserTotal = selectTotal; 

reducers / index.ts

 import { createSelector, createFeatureSelector, ActionReducerMap, } from '@ngrx/store'; import * as fromUser from './user.reducer'; export interface State { users: fromUser.State; } export const reducers: ActionReducerMap<State> = { users: fromUser.reducer, }; export const selectUserState = createFeatureSelector<fromUser.State>('users'); export const selectUserIds = createSelector( selectUserState, fromUser.selectUserIds ); export const selectUserEntities = createSelector( selectUserState, fromUser.selectUserEntities ); export const selectAllUsers = createSelector( selectUserState, fromUser.selectAllUsers ); export const selectUserTotal = createSelector( selectUserState, fromUser.selectUserTotal ); export const selectCurrentUserId = createSelector( selectUserState, fromUser.getSelectedUserId ); export const selectCurrentUser = createSelector( selectUserEntities, selectCurrentUserId, (userEntities, userId) => userEntities[userId] ); 


What is the result?


We get a full-fledged state management with a bunch of advantages:

- a single data source for the application,
- the state is kept separate from the application,
- common writing style for all developers in the project,
- changeDetectionStrategy.OnPush in all components of the application,
- convenient debugging via redux-devtools ,
- ease of testing, because reducers are “pure” functions.

But there are also disadvantages:

- a large number of modules that are incomprehensible at first glance,
- a lot of the same type of code, which you cannot see without sadness,
- Difficulty in mastering due to all of the above.

CRUD


As a rule, a significant part of the application is occupied by working with objects (creating, reading, updating, deleting), therefore, for convenience, the concept of CRUD (Create, Read, Update, Delete) was invented. Thus, the basic operations for working with all types of objects are standardized. On the back end, it has been flourishing for a long time. Many libraries help implement this functionality and get rid of the routine work.

In NgRx , the entity module is responsible for the CRUD , and if you look at an example of its implementation, you can immediately see that this is the largest and most complex part of NgRx . That is why John Papa and Ward Bell created angular-ngrx-data .

angular-ngrx-data


angular-ngrx-data is an add-on library over NgRx that allows you to work with data arrays without writing extra code.
In addition to creating a full-fledged state management , it takes on the creation of services with http to interact with the server.

Consider an example


Installation

 npm install --save @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools ngrx-data 

Angular-ngrx-data module

 import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { EntityMetadataMap, NgrxDataModule, DefaultDataServiceConfig } from 'ngrx-data'; const defaultDataServiceConfig: DefaultDataServiceConfig = { root: 'crud' }; export const entityMetadata: EntityMetadataMap = { Hero: {}, User:{} }; export const pluralNames = { Hero: 'heroes' }; @NgModule({ imports: [ CommonModule, NgrxDataModule.forRoot({ entityMetadata, pluralNames }) ], declarations: [], providers: [ { provide: DefaultDataServiceConfig, useValue: defaultDataServiceConfig } ] }) export class EntityStoreModule {} 

Connect to the application

 @NgModule({ imports: [ BrowserModule, HttpClientModule, StoreModule.forRoot({}), EffectsModule.forRoot([]), EntityStoreModule, StoreDevtoolsModule.instrument({ maxAge: 25, }), ], declarations: [ AppComponent ], providers: [], bootstrap: [AppComponent] }) export class AppModule {} 

We have just received the generated API for working with the back-end and the integration of the API with NgRx , without writing a single effect, reducer and action and selector.

We will analyze in more detail what is happening here.


The defaultDataServiceConfig constant sets the configuration for our API and connects to the providers module. The root property indicates where to turn for requests. If you do not specify it, the default is “api”.

 const defaultDataServiceConfig: DefaultDataServiceConfig = { root: 'crud' }; 

The entityMetadata constant defines the names of the stores that will be created when NgrxDataModule.forRoot is connected.

 export const entityMetadata: EntityMetadataMap = { Hero: {}, User:{} }; ... NgrxDataModule.forRoot({ entityMetadata, pluralNames }) 

The path to the API consists of the base path (in our case “crud”) and the name of the store.
For example, to get a user with a specific number, the path would be “crud / user / {userId}”.

For a complete list of users, the letter “s” - “crud / user s ” is added at the end of the name of the store.

If you need a different route to get a complete list (for example, “heroes”, not “heros”), you can change it by specifying pluralNames and connecting them to NgrxDataModule.forRoot .

 export const pluralNames = { Hero: 'heroes' }; ... NgrxDataModule.forRoot({ entityMetadata, pluralNames }) 

Connection in the component


To connect in the component, you must pass to the entityServices constructor and select the required storage service using the getEntityCollectionService method

 import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { Observable } from 'rxjs'; import { Hero } from '@appModels/hero'; import { EntityServices, EntityCollectionService } from 'ngrx-data'; @Component({ selector: 'app-heroes', templateUrl: './heroes.component.html', styleUrls: ['./heroes.component.css'], changeDetection: ChangeDetectionStrategy.OnPush }) export class HeroesComponent implements OnInit { heroes$: Observable<Hero[]>; heroesService: EntityCollectionService<Hero>; constructor(entityServices: EntityServices) { this.heroesService = entityServices.getEntityCollectionService('Hero'); } ... } 

To bind a list to a component, it is enough to take the entities $ property from the service, and to get data from the server, call the getAll () method.

 ngOnInit() { this.heroes$ = this.heroesService.entities$; this.heroesService.getAll(); } 

Also, in addition to the basic data, you can get:

- loaded $ , loading $ - getting data loading status,
- errors $ - errors during service operation,
- count $ - the total number of records in the repository.

The main methods of interaction with the server:

- getAll () - getting the entire list of data,
- getWithQuery (query) - getting the list filtered using query parameters,
- getByKey (id) - getting one record by ID,
- add (entity) - adding a new entity with a request for backing,
- delete (entity) - deleting an entity with a request for backing,
- update (entity) - update of the entity with a request for backing.

Methods of local work with the repository:

- addManyToCache (entity) - adding an array of new entities to the storage,
- addOneToCache (entity) - adding a new entity only to the repository,
- removeOneFromCache (id) - removal of one entity from the repository,
- updateOneInCache (entity) - update of the entity in the repository,
- upsertOneInCache (entity) - if an entity with the specified id exists, it is updated, if not, a new one is created,
- and etc.

Example of use in the component

 import { EntityCollectionService, EntityServices } from 'ngrx-data'; import { Hero } from '../../core'; @Component({ selector: 'app-heroes', templateUrl: './heroes.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class HeroesComponent implements OnInit { heroes$: Observable<Hero[]>; heroesService: EntityCollectionService<Hero>; constructor(entityServices: EntityServices) { this.heroesService = entityServices.getEntityCollectionService('Hero'); } ngOnInit() { this.heroes$ = this.heroesService.entities$; this.getHeroes(); } getHeroes() { this.heroesService.getAll(); } addHero(hero: Hero) { this.heroesService.add(hero); } deleteHero(hero: Hero) { this.heroesService.delete(hero.id); } updateHero(hero: Hero) { this.heroesService.update(hero); } } 

All methods of angular-ngrx-data are divided into locally working and interacting with the server. This allows you to use the library when manipulating data both on the client and using the server.

Logging


For logging, you need to inject EntityServices into a component or service and use the properties:

- reducedActions $ - for logging actions,
- entityActionErrors $ - for error logging.

 import { Component, OnInit } from '@angular/core'; import { MessageService } from '@appServices/message.service'; import { EntityServices } from 'ngrx-data'; @Component({ selector: 'app-messages', templateUrl: './messages.component.html', styleUrls: ['./messages.component.css'] }) export class MessagesComponent implements OnInit { constructor( public messageService: MessageService, private entityServices: EntityServices ) {} ngOnInit() { this.entityServices.reducedActions$.subscribe(res => { if (res && res.type) { this.messageService.add(res.type); } }); } } 

Moving to the main NgRx repository


As announced on ng-conf 2018 , angular-ngrx-data will soon be transferred to the main NgRx repository.

Video with Report Reducing the Boilerplate with NgRx - Brandon Roberts & Mike Ryan


Links


The creators of anguar-ngrx-data:
- John Papa twitter.com/John_Papa
- Ward Bell twitter.com/wardbell

Official repositories:
- NgRx
- angular-ngrx-data

Sample application:
- with NgRx without angular-ngrx-data
- with NgRx and angular-ngrx-data

Russian-speaking Angular community in Telegram

Source: https://habr.com/ru/post/418369/


All Articles