Datastore in Angular

image

State-machine - Midjourney

Why we need a datastore?

In Angular applications, a datastore is essential for managing state effectively, filling the gap between the stateless nature of functional programming and the dynamic needs of front-end applications. Angular relies on the functional approach using, for example, the observable pattern. It emphasizes processes and transformations over storing state. Such an approach requires developers to find state management solution for maintaining data consistency across multiple components.

The NGRX datastore enables the storage and synchronization of the application’s state at the global level. This ensures that all components have access to the up-to-date state, resulting in the smoother user experience and making developers’ life easier in state management across the application’s lifecycle.

This blog post will mainly focus on:

  • The structure of NGRX datastore
  • Managing state using datastore
  • Visualizing datastore
  • Using the datastore for managing API requests
  • Insights on build a scalable datastore
  • 1. The structure of NGRX datastore

    Let’s have a look at a simple example in which a front-end component interacts with the backend service and the local datastore.

    The black path illustrates the local front-end state changes, while the red path indicates the interaction with the backend – both update the state of the store. We focus on these flows in this section. The purple path is a read from the datastore and is covered in the next section.

    NGRX Structure

    This workflow diagram shows how different parts interact with the actual Store, which is the heart of the NGRX architecture that holds all the state of the application. Let’s look at the purpose of other parts.

    Selectors are used by the application to get specific data from the store. Think of queries for databases, of sorts.

    Actions tell the other components (such Reducers, Effects, and eventually the Store) what needs to be done, e.g., add a book to a collection, remove or update an item on the backend, etc.

    Reducers decide how the state should change in response to an action. They are pure functions: they take both the current state and the action and return a new state. They have no state of their own.

    Effects handle side effects, e.g., making API requests or receiving events via web sockets. They listen for actions, perform their task, and then dispatch new actions (and eventually to reducers and the store) based on the task’s results.

    All read operations follow the same purple path: we use a selector to choose data we need from the Store by providing the names of the data resources we wish to select.

    The write operations are more complicated and flexible. Red and black paths represent two common data write flows in the datastore (numbers indicate the order of execution):

    Black Path - Front-end State Management, with no backend interaction:

    1. A component dispatches an action (like Add Book).
    2. The action goes to a reducer.
    3. The reducer decides how to update the state.
    4. The updated state is stored in the store.

    Red Path - Backend Interaction:

    1. A component dispatches an action (like Fetch Data from Server).
    2. The action triggers an effect.
    3. The effect uses a service to interact with the database.
    4. Once the data is fetched, the effect dispatches a new action (like Data Fetch Succeed).
    5. This action goes to a reducer.
    6. The reducer updates the state based on this new data.
    7. The updated state is stored in the store.

    2. Managing state using datastore

    Let’s dive into a hands-on demo of how these components interact in the code. This section will cover the NgRx datastore read operations (the purple path) and write operations (the black path) in the image below.

    The wish list page is a good starting point for a bookstore:

    NGRX Structure

    Imagine you are developing a bookstore web application where you need to collect information about the books users intend to buy. In the following part of the blog, we will build a basic wishlist page for a bookstore, utilizing the NGRX datastore structure.

    Let’s start by constructing the wishlist submission form:

    // Wish list form
    <form myform="ngForm" ngSubmit="addBook(myform)" autocomplete="off">
    	<div class="form-group">
    		<label for="id">id</label>
    		<input
    			type="text"
    			class="form-control"
    			name="id"
    			id="id"
    			ngModel="bookFormId$ | async"
    			required
    		/>
    	</div>
    	<div class="form-group">
    		<label for="author">Author</label>
    		<input
    			type="text"
    			class="form-control"
    			ngModel
    			name="author"
    			id="author"
    			required
    		/>
    	</div>
    	<div class="form-group">
    		<label for="name">Book Name</label>
    		<input
    			type="text"
    			class="form-control"
    			ngModel
    			name="name"
    			id="name"
    		/>
    	</div>
    	<div class="form-group">
    		<label for="type">type</label>
    		<input
    			type="text"
    			class="form-control"
    			ngModel
    			name="type"
    			id="type"
    		/>
    	</div>
    	<button type="submit" class="btn btn-primary">Submit</button>
    </form>
    

    When a user clicks the Submit button, we want to record their submission into the datastore. Following the flow mentioned before, we need to define and fire the Add_Book action when the user submits the wishlist form:

    Define Actions

    export enum BookActionType {
    	Add_Book = '[Book] Add Book',
    }
    
    export class AddBookAction implements Action {
    	readonly type = BookActionType.Add_Book;
    	//add an optional payload
    	constructor(public payload: BookItem) {}
    }
    

    Dispatching Actions

    // Dispatch an action to add a new Book
    addBook(form: NgForm) {
    	this.store.dispatch(new AddBookAction(form.value)); 
    	form.reset();
    }
    

    Now we need to register a reducer that listens for the Add_Book action and updates the store accordingly (adds a new book to the existing wishlist):

    Reducers

    // Example BookReducer
    const BookReducer = (state, action) => {
      switch (action.type) {
        case BookActionType.Add_Book::
          return { ...state, books: [...state.books, action.payload] };
        // Handle other actions here
        default:
          return state;
      }
    };
    

    Users want to see their wishlists. For that we use selectors to read data from the datastore.

    Reading State

    // Display the books in the wish list 
    <div class="col-md-12">
    	<h4>Book wish list - NGRX demo</h4>
    </div>
    <div class="col-md-6">
    	<ul class="list-group">
    		<li class="list-group-item" ngFor="let Book of bookItems$ | async">
    			<b>{{ Book.author }} - {{ Book.name }}</b> - ( {{ Book.type }} )
    		</li>
    	</ul>
    </div>
    
    // Select the "books" state from the store
    ngOnInit(): void {
    	this.bookItems$ = this.store.select((store) => store.books);
    	this.bookFormId$ = this.store.select(
    		(store) => store.books[store.books.length - 1].id + 1
    	);
    }
    

    So far, we have implemented a book wishlist application using the NGRX datastore. It can add new book to wishlist and read the existing ones.

    Now, let’s bring all these pieces together and see how the application functions. You can access the repository of this project by clicking on this link, or explore the code in more details by unfolding the files below.

    app.module.ts

    import { NgModule } from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { FormsModule } from '@angular/forms';
    import { StoreModule } from '@ngrx/store';
    import { BookReducer } from 'src/store/reducers/Book.reducer';
    import { AppRoutingModule } from './app-routing.module';
    import { AppComponent } from './app.component';
    
    @NgModule({	
      declarations: [AppComponent],
      imports: [
        BrowserModule,
        AppRoutingModule,
        FormsModule,
        StoreModule.forRoot({
          books: BookReducer,
        }),
      ],
      providers: [],
      bootstrap: [AppComponent],
    })
    
    export class AppModule {}
    

    Book.action.ts

    import { Action } from '@ngrx/store';
    import { BookItem } from '../models/BookItem.model';
    
    export enum BookActionType {
      Add_Book = '[Book] Add Book',
    }
    
    export class AddBookAction implements Action {
      readonly type = BookActionType.Add_Book;
      //add an optional payload
      constructor(public payload: BookItem) {}
    }
    

    app-state.model.ts

    import { BookItem } from './BookItem.model';
    
    export interface AppState {
      readonly books: Array<BookItem>;
    }
    

    bookItem.model.ts

    export interface BookItem {
    	id: number;
    	author: string;
    	name: string;
    	type: string;
    }
    

    Book.reducer.ts

    import { Action } from '@ngrx/store';
    import { BookActionType } from '../actions/Book.action';
    import { BookItem } from '../models/BookItem.model';
    
    //create a dummy initial state
    const initialState: Array<BookItem> = [
      {
    	id: 1,
    	author: 'Mark Twain',
    	name: 'Jumping Frog of Calaveras County',
    	type: 'Classical',
      },
    ];
    
    export function BookReducer(
      state: Array<BookItem> = initialState,
      action: Action
    ) {
      switch (action.type) {
        case BookActionType.Add_Book:
          const addBookAction = action as AddBookAction;
          return [...state, addBookAction.payload];
        default:
          return state;
      }
    }
    

    app.component.ts

    import { Component, OnInit, ViewChild } from '@angular/core';
    import { NgForm } from '@angular/forms';
    import { Store } from '@ngrx/store';
    import { Observable } from 'rxjs';
    import { AddBookAction } from 'src/store/actions/book.action';
    import { AppState } from 'src/store/models/app-state.model';
    import { BookItem } from '../store/models/bookItem.model';
    
    @Component({
    	selector: 'app-root',
    	template: `<section>
    		<div class="container">
    		<div class="row">
    			<div class="col-md-12">
    				<h4>Book wish list - NGRX demo</h4>
    			</div>
    			<div class="col-md-6">
    			<ul class="list-group">
    			<li class="list-group-item" *ngFor="let Book of bookItems$ | async">
    				<b>{{ Book.author }} - {{ Book.name }}</b> - ( {{ Book.type }} )
    			</li>
    			</ul>
    			</div>
    			<div class="col-md-6">
    			<div class="card p-4 shadow-sm">
    				<form
    					#myform="ngForm"
    					(ngSubmit)="addBook(myform)"
    					autocomplete="off"
    				>
    				<div class="form-group">
    					<label for="id">id</label>
    					<input
    						type="text"
    						class="form-control"
    						ngModel
    						name="id"
    						id="id"
    						[ngModel]="bookFormId$ | async"
    						aria-describedby="book id"
    						required
    					/>
    				</div>
    				<div class="form-group">
    					<label for="author">Author</label>
    					<input
    						type="text"
    						class="form-control"
    						ngModel
    						name="author"
    						id="author"
    						aria-describedby="Author"
    						required
    					/>
    				</div>
    				<div class="form-group">
    					<label for="name">Book Name</label>
    					<input
    						type="text"
    						class="form-control"
    						ngModel
    						name="name"
    						id="name"
    						aria-describedby="book name"
    					/>
    				</div>
    				<div class="form-group">
    					<label for="type">type</label>
    					<input
    						type="text"
    						class="form-control"
    						ngModel
    						name="type"
    						id="type"
    					/>
    				</div>
    				<button type="submit" class="btn btn-primary">Submit</button>
    				</form>
    			</div>
    			</div>
    		</div>
    		</div>
    	</section>`,
    })
    
    export class AppComponent implements OnInit {
    	bookItems$: Observable<Array<BookItem>> | undefined;
    	bookFormId$: Observable<number> | undefined;
    
    	@ViewChild('myform') myForm: NgForm | undefined;
    
    	constructor(private store: Store<AppState>) {}
    	ngOnInit(): void {
    		this.bookItems$ = this.store.select((store) => store.books);
    		this.bookFormId$ = this.store.select(
    			(store) => store.books[store.books.length - 1].id + 1
    		);
    	}
    
    	//create the method for adding a new Book and then reset the form
    
    	addBook(form: NgForm) {
    		this.store.dispatch(new AddBookAction(form.value)); 
    		form.reset();
    	}
    }
    

    If everything is configured correctly, you should be able to observe something similar to the video below:

    3. Visualizing datastore

    A debugger is your friend when you’re developing something complex. Redux visualization tool provides you an easy GUI to check what action has been fired and what data has been put into the store. I highly recommend using it instead of sifting through the massive console log output.

    To install, use the npm package manager:

    npm install @ngrx/store-devtools
    

    Then, configure it in the app.module.ts file to ensure it’s correctly imported:

    imports: [
    ... // all the previous import statement
    StoreDevtoolsModule.instrument({
    	maxAge: 25, // config the maximum number of actions stored in the history tree
    }),
    ]
    

    Once you’ve completed the above steps, you should be able to access the Redux visualization tool in your browser’s developer tools and observe the following:

    4. Using datastore for managing API requests

    So far we have the NGRX data store working and have the right tool for monitoring. The datastore can bring backend data closer to the web application. By storing API call results in it, we have a centralized location for all the state data in our application. This section covers the datastore write operations marked by the red path in the image below.

    NGRX Structure

    NGRX effects handle async requests to backend APIs and pass the results to reducers. They listen for actions triggered by the application, similarly to reducers. The difference is that the reducers are pure functions responsible for updating the application state based on dispatched actions (such as success or failure actions from NGRX effects).

    In the current version of our web app, BookReducer captures the Add_Book action from the form and adds the new book data to the datastore. Now we want to make an API request to the backend and update the datastore depending on the response from the server.

    For that, our new effect will listen to the Add_Book action (instead of BookReducer), call the backend, and issue a new action, Success. The BookReducer will listen to this new action and update the Store.

    The following code section implements the updated data store design.

    Create an Effect: Define an NGRX effect to handle the HTTP request when a Book is added and to dispatch a new Success action.

      addBook$ = createEffect(() =>
        this.actions.pipe(
          ofType(BookActions.BookActionType.Add_Book), // Listen for the 'Add_Book' action
              mergeMap((action) => {
              return this.api.fakeApiCall().pipe(
                map((result) => {
                  return new BookActions.SuccessAction(result);
              })
            );
          })
        )
      );
    

    And the success action is defined as follows:

    export class SuccessAction implements Action {
    	readonly type = BookActionType.Success;
    	//add an optional payload
    	constructor(public payload: BookItem) {
    	}
    }
    

    Service Layer: Ensure that you have a service layer (e.g., ApiService) that encapsulates the HTTP requests and communicates with your backend API.

    export class ApiService {
    	constructor() {}
    	fakeApiCall(): Observable<BookItem> {
    		return interval(1000).pipe(
    			take(1),
    				map(() => {
    				return {
    				id: 2,
    				author: 'William Shakespeare',
    				name: 'Henry VI',
    				type: 'classical',
    			};
    		}));
    	}
    }
    

    Import the effect into app module:

    imports: [
    	EffectsModule.forRoot([BookEffects]),
    ]
    

    Update the reducer to handle the Success action that is dispatched when the HTTP request is successful. This action should update the application state with the new data.

    export function BookReducer(
    	state: Array<BookItem> = initialState,
    	action: Action
    ) {
    	switch (action.type) {
    		case BookActionType.Success:
    			const addBookAction = action as SuccessAction;
    				return [...state, addBookAction.payload];
    		default:
    			return state;
    	}
    }
    

    Update the book action to ensure that the success action is triggered when the API response is successful.

    This design keeps your application architecture clean and maintainable. Reducers handle state changes, effects manage side effects, and services handle API interactions. This approach aligns with the principles of the NGRX library, making your code easier to test and scale.

    The updated repo can be found at this link. You can also unfold the files listed below to view the raw code:

    Book-effects.ts

    import { Injectable } from '@angular/core';
    import { Actions, createEffect, ofType } from '@ngrx/effects';
    import { map, mergeMap } from 'rxjs/operators';
    import { ApiService } from 'src/services/api.service';
    import * as BookActions from '../actions/Book.action';
    
    @Injectable()
    export class BookEffects {
      constructor(private actions: Actions, private api: ApiService) {}
    
      addBook$ = createEffect(() =>
        this.actions.pipe(
          ofType(BookActions.BookActionType.Add_Book), // Listen for the 'addBook' action
          mergeMap((action) => {
            return this.api.fakeApiCall().pipe(
              map((result) => {
                return new BookActions.SuccessAction(result);
              })
            );
          })
        )
      );
    }   
    

    api.service.ts

    import { Injectable } from '@angular/core';
    import { Observable, interval, map, take } from 'rxjs';
    import { BookItem } from 'src/store/models/BookItem.model';
    	
    	@Injectable({
    	  providedIn: 'root',
    	})
    	
    	export class ApiService {
    	  constructor() {}
    	
    	  fakeApiCall(): Observable<BookItem> {
    	    return interval(1000).pipe(
    	      take(1),
    	      map(() => {
    	      return {
    		      id: 2,
    		      author: 'William Shakespeare',
    		      name: 'Henry VI',
    		      type: 'classical',
    	      };
    	 })
    	);}
    }
    

    app.module.ts

    import { NgModule } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    import { StoreModule } from '@ngrx/store';
    import { BookReducer } from 'src/store/reducers/Book.reducer';
    import { AppRoutingModule } from './app-routing.module';
    import { AppComponent } from './app.component';
    import { EffectsModule } from '@ngrx/effects';
    import { StoreDevtoolsModule } from '@ngrx/store-devtools';
    import { BookEffects } from 'src/store/effects/Book-effects';
    	
    
    @NgModule({
    	declarations: [AppComponent],
    	imports: [
    	StoreDevtoolsModule,
    	BrowserModule,
    	AppRoutingModule,
    	FormsModule,
    	StoreModule.forRoot({
    		books: BookReducer,
    	}),
    	StoreDevtoolsModule.instrument({
    		maxAge: 25,
    	}),
    	EffectsModule.forRoot([BookEffects]),
    	],
    	providers: [],
    	bootstrap: [AppComponent],
    })
    
    export class AppModule {}
    

    Book.reducer.ts

    import { Action } from '@ngrx/store';
    import { BookActionType } from '../actions/Book.action';
    import { BookItem } from '../models/BookItem.model';
    
    //create a dummy initial state
    const initialState: Array<BookItem> = [
      {
      id: 1,
      author: 'Mark Twain',
      name: 'Jumping Frog of Calaveras County',
      type: 'Classical',
      },
    ];
    
    export function BookReducer(
      state: Array<BookItem> = initialState,
      action: Action
    ) {
      switch (action.type) {
        case BookActionType.Success:
          const addBookAction = action as SuccessAction;
          return [...state, addBookAction.payload];
        default:
          return state;
      }
    }
    

    Book.action.ts

    import { Action } from '@ngrx/store';
    
    export enum BookActionType {
    	Add_Book = '[Book] Add Book',
    	Success = '[API] Succeed',
    }
    
    export class AddBookAction implements Action {
    	readonly type = BookActionType.Add_Book;
    	//add an optional payload
    	constructor(public payload: BookItem) {}
    }
    
    export class SuccessAction implements Action {
    	readonly type = BookActionType.Success;
    	//add an optional payload
    	constructor(public payload: BookItem) {
    	}
    }
    

    The Redux tool shows two actions triggered upon submitting a new book entry via the form. The first action is Add_Book, which is fired instantly upon submitting the form data. The second action occurs upon successful API request. In this project we are simulating the API result by delaying the firing of the success action via interval(1000) in api.service.ts file.

    5. Insights on building a scalable datastore

    So far, we have successfully developed a basic datastore that interacts with both user state and backend data. As you may have noticed, our codebase includes various functions such as store.select, store.dispatch, and createEffect. This implies that any developer aiming to develop new features involving API data retrieval must first become familarize themselves with the datastore concept and then replicate similar implementations. This requirement can be daunting for newcomers, who may find it challenging to quickly grasp all the new tools and paradigms. Additionally, allowing beginners to use complex tools without a deep understanding of them can be very risky. To mitigate these issues, we can construct a custom wrapper around the NGRX datastore features, offering a simplified interface for developers. This approach not only eases the learning curve but also simplifies future upgrades. Imagine the convenience of not having to search through the entire codebase to replace every instance of store.select, store.dispatch, and createEffect with their updated versions!

    As the application grows, automated testing becomes increasingly important (and not optional!) Datastore architecture facilitates UI testing through its inherent feature of switching data sources. For example, it’s quite straightforward to shift from real backend data fetched via HTTP requests to using mocked data fed directly into the datastore.

    This capability allows the application to operate even without having an actual backend structure in place. This means that we can further utilize datastore to quicky design a prototype or a product demo. This blog post aims to equip you with an understanding of how the NGRX datastore functions, both in theory and in practice. In the next post, I will cover building a custom wrapper for the NGRX datastore and demonstrate how to utilize it in UI testing.

    Thank you for reading. I hope you found the content useful. 🐻


    Reference:
    https://NGRX.io/guide/store
    https://v8.NGRX.io/guide/data/architecture-overview
    https://blog.logrocket.com/angular-state-management-made-simple-with-NGRX/
    https://www.thisdot.co/blog/working-with-NGRX-effects/