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.

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 synchronise with your datastore through a backend Web API:

Application data persistence

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.

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 }>()
);

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 }))
          })
        )
    })
  ))
}

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 {}

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
);

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.");
    }
}

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 { }

When run, 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