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

  • Tutorial
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-fledged CRUD with state management in five minutes.

NgRx Review


You can read more about NgRx in the following articles:

- Reactive applications on Angular / NGRX. Part 1. Introduction
- Angular / NGRX Reactive Applications. 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
exportconst INCREMENT = 'INCREMENT';
exportconst DECREMENT = 'DECREMENT';
exportconst RESET = 'RESET';

counter.reducer.ts

import { Action } from'@ngrx/store';
const initialState = 0;
exportfunctioncounterReducer(state: number = initialState, action: Action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      return state - 1;
    case RESET:
      return0;
    default:
      return state;
  }
}
.
Подключение в модуль
import { NgModule } from'@angular/core';
import { StoreModule } from'@ngrx/store';
import { counterReducer } from'./counter';
@NgModule({
  imports: [StoreModule.forRoot({ count: counterReducer })],
})
exportclassAppModule {}

Использование в компоненте
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { INCREMENT, DECREMENT, RESET } from './counter';
interfaceAppState{
  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 classMyCounterComponent{
  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 classAppModule{}


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()
exportclassAuthEffects{
  // 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) {}
}

Подключение эффекта в модуль

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


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

Example
user.model.ts

exportinterfaceUser {
  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',
}
exportclassLoadUsersimplementsAction{
  readonly type = UserActionTypes.LOAD_USERS;
  constructor(public payload: { users: User[] }) {}
}
exportclassAddUserimplementsAction{
  readonly type = UserActionTypes.ADD_USER;
  constructor(public payload: { user: User }) {}
}
exportclassUpsertUserimplementsAction{
  readonly type = UserActionTypes.UPSERT_USER;
  constructor(public payload: { user: User }) {}
}
exportclassAddUsersimplementsAction{
  readonly type = UserActionTypes.ADD_USERS;
  constructor(public payload: { users: User[] }) {}
}
exportclassUpsertUsersimplementsAction{
  readonly type = UserActionTypes.UPSERT_USERS;
  constructor(public payload: { users: User[] }) {}
}
exportclassUpdateUserimplementsAction{
  readonly type = UserActionTypes.UPDATE_USER;
  constructor(public payload: { user: Update<User> }) {}
}
exportclassUpdateUsersimplementsAction{
  readonly type = UserActionTypes.UPDATE_USERS;
  constructor(public payload: { users: Update<User>[] }) {}
}
exportclassDeleteUserimplementsAction{
  readonly type = UserActionTypes.DELETE_USER;
  constructor(public payload: { id: string }) {}
}
exportclassDeleteUsersimplementsAction{
  readonly type = UserActionTypes.DELETE_USERS;
  constructor(public payload: { ids: string[] }) {}
}
exportclassClearUsersimplementsAction{
  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;
}
exportconst adapter: EntityAdapter<User> = createEntityAdapter<User>();
exportconst initialState: State = adapter.getInitialState({
  // additional entity state properties
  selectedUserId: null,
});
exportfunctionreducer(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;
    }
  }
}
exportconst getSelectedUserId = (state: State) => state.selectedUserId;
// get the selectorsconst { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
// select the array of user idsexportconst selectUserIds = selectIds;
// select the dictionary of user entitiesexportconst selectUserEntities = selectEntities;
// select the array of usersexportconst selectAllUsers = selectAll;
// select the total user countexportconst selectUserTotal = selectTotal;

reducers/index.ts

import {
  createSelector,
  createFeatureSelector,
  ActionReducerMap,
} from'@ngrx/store';
import * as fromUser from'./user.reducer';
export interface State {
  users: fromUser.State;
}
exportconst reducers: ActionReducerMap<State> = {
  users: fromUser.reducer,
};
exportconst selectUserState = createFeatureSelector<fromUser.State>('users');
exportconst selectUserIds = createSelector(
  selectUserState,
  fromUser.selectUserIds
);
exportconst selectUserEntities = createSelector(
  selectUserState,
  fromUser.selectUserEntities
);
exportconst selectAllUsers = createSelector(
  selectUserState,
  fromUser.selectAllUsers
);
exportconst selectUserTotal = createSelector(
  selectUserState,
  fromUser.selectUserTotal
);
exportconst selectCurrentUserId = createSelector(
  selectUserState,
  fromUser.getSelectedUserId
);
exportconst 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 stored separately from the application,
- a single writing style for all developers in the project,
- changeDetectionStrategy.OnPush in all application components,
- 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 that you cannot look at 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's 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'
};
exportconst entityMetadata: EntityMetadataMap = {
 Hero: {},
 User:{}
};
exportconst pluralNames = { Hero: 'heroes' };
@NgModule({
 imports: [
   CommonModule,
   NgrxDataModule.forRoot({ entityMetadata, pluralNames })
 ],
 declarations: [],
 providers: [
   { provide: DefaultDataServiceConfig, useValue: defaultDataServiceConfig }
 ]
})
exportclassEntityStoreModule{}

Connect to the application

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

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 .

exportconst 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 .

exportconst 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 via 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
})
exportclassHeroesComponentimplementsOnInit{
  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 main data, you can get:

- loaded $ , loading $ - getting the status of data loading,
- errors $ - errors when the service is running,
- count $ - total number of records in the repository.

Main methods of interaction with the server:

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

Methods of local work with the repository:

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

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 classHeroesComponentimplementsOnInit{
  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 logging errors.

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']
})
exportclassMessagesComponentimplementsOnInit{
  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


Anguar-ngrx-data creators:
- 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

Also popular now: