Data persistence
Angular NgRx Redux SPA Typescript Visual Studio Code

How to Implement CRUD Operations with NgRx Store

Welcome to today’s post.            

I will discuss how to use NgRx Store to create CRUD based Angular application.

In most applications where there is a need for using CRUD based operations to manage data in a backend store, we would use a data provider to connect to the data store, then implement some routines that provide typical select, insert, update, and delete operations.

With Angular applications, because the application is a web client application, we cannot directly use a data provider within the application source. In this case we would need to implement a separate HTTP REST Web API service that includes methods that provide the select, insert, update, and delete operations in the form of GET, POST, PUT and DELETE HTTP requests.

What is NgRx Store?

NgRx Store is a third-party library that provides a framework for reactive applications. These include state management and development debugging tools.

In a previous post I showed how to install NgRx in Visual Studio Code and install the extension within the Chrome browser extensions.

Below is our overview of the NgRx library and how they synchronize with your datastore through a backend Web API:

Application data persistence

Implementation of Ngrx CRUD Operations from a Web API

A dependency for Ngrx CRUD operations is to provide an existing Web API service that contains the equivalent HTTP REST methods that provide these CRUD operations.

Starting with an existing Web API service

Suppose our backend Web API service which consists of API HTTP REST calls for the main CRUD operations for selecting, inserting, updating, and deleting as shown:

import { Injectable } from '@angular/core'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import { Book } from 'src/app/models/Book';
import { Subject} from 'rxjs'

@Injectable({
    providedIn: 'root'
})
export class BookApiService {

    private selectedBook = new Subject<any>();
    bookSelected = this.selectedBook.asObservable();

    constructor(private http: HttpClient) {}

    getBooks(){
        return this.http.get('http://localhost/BookLoan.Catalog.API/api/Book/List'); 
    }

    getBook(id: number)
    {
        return this.http.get('http://localhost/BookLoan.Catalog.API/api/Book/Details/' + id); 
    }

    selectBook(book) {
        this.selectedBook.next(book)
    }

    saveBook(book) 
    {
        return this.http.post<Book>('http://localhost/BookLoan.Catalog.API/api/Book/Create', book);
    }

    updateBook(book) 
    {
        return this.http.post<Book>('http://localhost/BookLoan.Catalog.API/api/Book/Edit/' + book.id, book);
    }

    deleteBook(id) 
    {
        return this.http.delete<Book>('http://localhost/BookLoan.Catalog.API/api/Book/Delete/' + id);
    }    
}

For our data to persist within our application, we have corresponding CRUD operations of LOAD, SAVE, UPDATE and DELETE, and each operation has a clearly defined completion result: REQUEST, FAILURE and SUCCESS.

Defining the Actions

We then define the actions, which are the basic building blocks and unique events for our features within the application. These are defined as shown:

import { Action, createAction, props } from '@ngrx/store';
import { Book } from '../../models/Book';

export enum ActionTypes {
  LOAD_BOOK_REQUEST = '[Book] Load Book Request',
  LOAD_BOOK_FAILURE = '[Book] Load Book Failure',
  LOAD_BOOK_SUCCESS = '[Book] Load Book Success',

  LOAD_REQUEST = '[Book] Load Request',
  LOAD_FAILURE = '[Book] Load Failure',
  LOAD_SUCCESS = '[Book] Load Success',

  SAVE_REQUEST = '[Book] Save',
  SAVE_FAILURE = '[Book] Save Failure',
  SAVE_SUCCESS = '[Book] Save Success',

  UPDATE_REQUEST = '[Book] Update',
  UPDATE_FAILURE = '[Book] Update Failure',
  UPDATE_SUCCESS = '[Book] Update Success',

  DELETE_REQUEST = '[Book] Delete',
  DELETE_FAILURE = '[Book] Delete Failure',
  DELETE_SUCCESS = '[Book] Delete Success'
}

We then export the combinations of the above actions for the rest of our application. For each API service method, we have three results. In all we have 15 actions to export. These are shown below:

export const loadBookRequestAction = createAction(
  ActionTypes.LOAD_BOOK_REQUEST,
  props<{ id: number }>()
);

export const loadBookSuccessAction = createAction(
  ActionTypes.LOAD_BOOK_SUCCESS,
  props<{ book: Book }>()
);

export const loadBookFailureAction = createAction(
  ActionTypes.LOAD_BOOK_FAILURE,
  props<{ error: string }>()
);

///////

export const loadRequestAction = createAction(
  ActionTypes.LOAD_REQUEST
);

export const loadFailureAction = createAction(
  ActionTypes.LOAD_FAILURE,
  props<{ error: string }>()
);

export const loadSuccessAction = createAction(
  ActionTypes.LOAD_SUCCESS,
  props<{ items: Book[] }>()
);

////////

export const saveRequestAction = createAction(
  ActionTypes.SAVE_REQUEST,
  props<{ item: Book }>()
);

export const saveFailureAction = createAction(
  ActionTypes.SAVE_FAILURE,
  props<{ error: string }>()
);

export const saveSuccessAction = createAction(
  ActionTypes.SAVE_SUCCESS,
  props<{ item: Book }>()
);

///

export const updateRequestAction = createAction(
  ActionTypes.UPDATE_REQUEST,
  props<{ item: Book }>()
);

export const updateFailureAction = createAction(
  ActionTypes.UPDATE_FAILURE,
  props<{ error: string }>()
);

export const updateSuccessAction = createAction(
  ActionTypes.UPDATE_SUCCESS,
  props<{ item: Book }>()
);
  
////

export const deleteRequestAction = createAction(
  ActionTypes.DELETE_REQUEST,
  props<{ id: number }>()
);

export const deleteFailureAction = createAction(
  ActionTypes.DELETE_FAILURE,
  props<{ error: string }>()
);
  
export const deleteSuccessAction = createAction(
  ActionTypes.DELETE_SUCCESS,
  props<{ id: number }>()
);

Defining the Effects

Next, we create effects to consume and manipulate data from our service, which we inject into our effects class. There are five API service methods, so there will be the same number of effects. With each effect we will map a successful result into the SUCCESS result and catch any possible failures into the FAILURE result.

import { Injectable } from '@angular/core';
import { Actions, Effect, ofType, createEffect } from '@ngrx/effects';
import { Observable, of as observableOf } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { BookApiService } from '../../services/bookApi.service';
import * as bookActions from './actions';

@Injectable()
export class BookStoreEffects {
  constructor(private dataService: BookApiService, private actions$: Actions) {}
 
  loadBookRequestEffect$ = createEffect(() => this.actions$.pipe(
    ofType(bookActions.loadBookRequestAction),
      switchMap(action => {
        const subject = "Book";
        return this.dataService.getBook(action.id).pipe(
          map((book: any) => {
              return bookActions.loadBookSuccessAction({ book })
          }),
          catchError((error: any) => {
            return observableOf(bookActions.loadBookFailureAction({ error }))
          })
        )
      })
  ))

  loadRequestEffect$ = createEffect(() => this.actions$.pipe(
    ofType(bookActions.loadRequestAction),
      switchMap(action => {
        const subject = "Books";      
        return this.dataService.getBooks().pipe(
          map((items: any[]) => {
              return bookActions.loadSuccessAction({ items })
          }),
          catchError(error => {
            return observableOf(bookActions.loadFailureAction({ error }))
          })
        )
      })
  ))

  saveRequestEffect$ = createEffect(() => this.actions$.pipe(
    ofType(bookActions.saveRequestAction),
      switchMap(action => {
        const subject = "Book";      
        return this.dataService.saveBook(action.item).pipe(
          map((item: any) => {
              return bookActions.saveSuccessAction({ item })
          }),
          catchError(error => {
            return observableOf(bookActions.saveFailureAction({ error }))
          })
        )
      })
  ))

  updateRequestEffect$ = createEffect(() => this.actions$.pipe(
    ofType(bookActions.updateRequestAction),
    switchMap(action => {
      return this.dataService.updateBook(action.item).pipe(
          map((item: any) => {
              return bookActions.updateSuccessAction({ item })
          }),
          catchError(error => {
            return observableOf(bookActions.updateFailureAction({ error }))
          })
        )
      })
  ))

  deleteRequestEffect$ = createEffect(() => this.actions$.pipe(
    ofType(bookActions.deleteRequestAction),
    switchMap(action => {
      return this.dataService.deleteBook(action.id).pipe(
          map((item: any) => {
              return bookActions.deleteSuccessAction({ id: action.id })
          }),
          catchError(error => {
            return observableOf(bookActions.deleteFailureAction({ error }))
          })
        )
    })
  ))
}

Defining the Reducers

Our reducers which handle the transition from one state to another within our application for a given loading state. We have one reducer for each action, a total of fifteen. These are implemented as shown:

import { initialState } from './state';
import { createReducer, on } from '@ngrx/store';
import * as BookActionTypes from './actions';

export const bookReducer = createReducer(
  initialState,
  on(BookActionTypes.loadBookRequestAction, (state, {id}) => ({
    ...state,
    isLoading: true  
  })),

  on(BookActionTypes.loadBookSuccessAction, (state, { book }) => ({
      ...state,
      isLoading: false,
      selectedBook: book
  })),

  on(BookActionTypes.loadBookFailureAction, (state, { error }) => ({
    ...state,
    isLoading: false,
    error: error
  })),

  on(BookActionTypes.loadRequestAction, state => ({
    ...state,
    isLoading: true  
  })),

  on(BookActionTypes.loadSuccessAction, (state, { items }) => ({
      ...state,
      isLoading: false,
      books: items
  })),

  on(BookActionTypes.loadFailureAction, (state, { error }) => ({
    ...state,
    isLoading: false,
    error: error
  })), 

  on(BookActionTypes.saveRequestAction, state => ({
    ...state,
    isLoading: true  
  })),

  on(BookActionTypes.saveSuccessAction, (state, { item }) => ({
    ...state,
    isLoading: false,
    selectedBook: item,
    error: null
  })),

  on(BookActionTypes.saveFailureAction, (state, { error }) => ({
    ...state,
    isLoading: false,
    error: error
  })), 

  on(BookActionTypes.updateRequestAction, state => ({
    ...state,
    isLoading: true  
  })),

  on(BookActionTypes.updateSuccessAction, (state, { item }) => ({
    ...state,
    isLoading: false,
    selectedBook: item,
    error: null
  })),

  on(BookActionTypes.updateFailureAction, (state, { error }) => ({
    ...state,
    isLoading: false,
    error: error
  })), 

  on(BookActionTypes.deleteRequestAction, state => ({
    ...state,
    isLoading: true  
  })),

  on(BookActionTypes.deleteSuccessAction, (state, { id }) => ({
    ...state,
    isLoading: false,
    books: state.books.filter(x => x.id != id)
  })),

  on(BookActionTypes.deleteFailureAction, (state, { error }) => ({
    ...state,
    isLoading: false,
    error: error
  }))
);

To import the feature and reducers within the store, we create an export module:

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { BookStoreEffects } from './effects';
import { bookReducer } from './reducer';

@NgModule({
  imports: [
    CommonModule,
    StoreModule.forFeature('bookStore', bookReducer),
    EffectsModule.forFeature([BookStoreEffects])
  ],
  providers: [BookStoreEffects]
})
export class BookStoreModule {}

Define the Selectors

Before our component can consume the store data, we require a selector implementation. A selector is a helper which allows us from within our components to efficiently access data from the global persistent data store. The selectors are defined as shown:

import {
  createSelector
} from '@ngrx/store';
    
import { Book } from '../../models/Book';

import { AppState } from '../state';
import { BookState } from './state';

const BookFeature = (state: AppState) => {
  return state.bookStore
};

export const getBooks = createSelector(
  BookFeature,
  (state: BookState) => state.books
);

export const getBook = createSelector(
  BookFeature,
  (state: BookState, id: number) => state.books.filter(x=> x.id === id)
);

export const getSelectedBook = createSelector(
  BookFeature,
  (state: BookState) => state.selectedBook
);

export const getBookError = createSelector(
  BookFeature,
  (state: BookState) => state.error
);

export const getBookIsLoading = createSelector(
  BookFeature,
  (state: BookState) => state.isLoading
);

Define the Entity State

The entity state which wraps the Book entity into an interface, defines the loading state and any subsequent errors. We also define an initial state for our data to an empty array and the currently selected book as empty. These definitions are shown below:

import { createEntityAdapter, EntityAdapter, EntityState } 
from '@ngrx/entity';

import { Book } from '../../models/Book';

export interface BookState {
  selectedBook: Book,
  books: Book[],  
  isLoading?: boolean; 
  error?: any;
}

export const initialState: BookState = 
{
    selectedBook: null,
    books: [],  
    isLoading: false,
    error: null
};

Our component can then consume the data using one of the selectors we have created.

import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router';
import { Store, StoreRootModule } from '@ngrx/store';
import { Observable, pipe, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { Book } from '../models/Book';
import { BookState } from 'src/app/root-store/book-store/state';

import {
    BookStoreState,
    BookStoreActions,
    BookStoreSelectors,
    BookStoreModule
} from '../root-store/book-store/index';

import { AppState } from 'src/app/root-store/state';
  
@Component({
    selector: 'books',
    templateUrl: './books.component.html'
})
export class BooksComponent implements OnInit {
    books$: Observable<Book[]>;
    error$: Observable<any>;
    isLoading$: Observable<boolean>;

    constructor(
        private store$: Store<AppState>,
        private router: Router) 
    {
    }

    ngOnInit() {
        this.store$.dispatch(BookStoreActions.loadRequestAction());

        this.store$.select(BookStoreSelectors.getBooks).subscribe(
            books => 
            {
                this.books$ = of(this.books);
            }
        );
    
        this.error$ = this.store$.select(BookStoreSelectors.getBookError);
        this.isLoading$ = this.store$.select(BookStoreSelectors.getBookIsLoading);
    }

    onRefresh()
    {
        this.store$.dispatch(BookStoreActions.loadRequestAction());
    }

    selectBookById(id) {
        this.router.navigate(['book-detail', id]);     
    }

    eventNewRecord(event)
    {
        this.router.navigate(['book-new']);     
        console.log("new book data entry form being opened.");
    }
}

Importing the necessary modules

In our feature root module, we import the feature module and effects as shown:

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { BookStoreEffects } from './effects';
import { bookReducer } from './reducer';

@NgModule({
  imports: [
    CommonModule,
    StoreModule.forFeature('bookStore', bookReducer),
    EffectsModule.forFeature([BookStoreEffects])
  ],
  providers: [BookStoreEffects]
})
export class BookStoreModule {}

In our root module we import the dev tools module as shown:

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { BookStoreModule } from './book-store/book-store.module';

@NgModule({
  imports: [
    CommonModule,
    BookStoreModule,
    StoreModule.forRoot({}),
    EffectsModule.forRoot([]),
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
    }),
  ],
  declarations: []
})
export class RootStoreModule {}

In your app module import the root module as shown:

..
import { RootStoreModule } from './root-store';
..
@NgModule({
  declarations: [
..
  ],
  imports: [
	..
    RootStoreModule
  ],
  providers: [
	..
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Testing and Debugging the NgRx CRUD Implementation

When run the application with the above implementation, you should see the data consumed and displayed by the component:

Try selecting an existing record:

When the record is successfully loaded the action “[Book] Load Book Success” will display in the Redux debug inspector:

Change a field value. (e.g. the ISBN field) and click on Update.

During the record update, there will be two states:

[Book] Update

[Book] Update Success

During the update state, the Diff inspector will show the fields changed in the record update.

The update success state will show the Error and IsLoading states to confirm a successful or unsuccessful update:

Expanding the selectedBook node which contains the details of the updated record will show which fields have been changed during the update.

You can validate the backend SQL data has changed by checking corresponding data using SSMS:

As you can see, we have managed to successfully implement a CRUD framework using NgRx with the Redux tools helping us track the state of the persisted application data.

It can be quite daunting to put together the plumbing needed to implement global application data persistence using NgRx within Angular, with the final result giving us elegant and polished method of retrieving and manipulating data.

With that said, are you still a believer in NgRx, or do toy prefer to stick to the tried and tested out of the box methods that Angular provides? I’ll leave you to decide.

That’s all for today’s post.

I hope you found this post useful and informative.

Social media & sharing icons powered by UltimatelySocial