DEV Community

loading...
Cover image for Introduction to the NGRX Suite, Part 1

Introduction to the NGRX Suite, Part 1

ngconf profile image ng-conf ・21 min read

Jim Armstrong | ng-conf | Oct 2020

alt text

NgRx state management, courtesy https://ngrx.io/guide/store

An organized introduction to @ ngrx/store, @ ngrx/effects, and @ ngrx/entity

Introduction

This article is intended for relatively new Angular developers who are just starting to work with an organized store in their applications. The NgRx suite is one of the most popular frameworks for building reactive Angular applications. The toolset does, however, come with a learning curve, especially for those not previously familiar with concepts such as Redux.

In talking with new Angular developers, a common communication is frustration with moving from online tutorials such as counters and TODO apps to actual applications. This article attempts to bridge that gap with an organized and phased introduction to @ ngrx/store, @ ngrx/effects, and @ ngrx/entity.

Instead of discussing all three tools in one massive tutorial, the application in this series is broken into four parts. This application is an extension of a quaternion calculator that I have frequently used as a ‘Hello World’ project for testing languages and frameworks. This calculator has been extended to more closely resemble a practical application that might be developed for an EdTech client.

Now, if the term quaternions sounds mathematical and scary, don’t worry. If you have read any of my past articles, then you know we have a tried and true technique for dealing with pesky math formulas. Here it goes …

blah, blah … math … blah, blah … quaternions … blah, blah … API.

Ah, there. We’re done :). Any math pertaining to quaternions is performed by my Typescript Math Toolkit Quaternion class. The Typescript Math Toolkit is a private library developed for my clients, but many parts of it have been open-sourced.

All you need in order to understand this tutorial series is:

1 — Some prior exposure to @ ngrx/store; at least a counter or TODO app (see the docs at https://ngrx.io/docs, for example).

2 — Ability to work with a data structure containing four numbers.

3 — Ability to call an API for add, subtract, multiply, and divide.

4 — Exposure to basic Angular concepts and routing, including feature modules and lazy-loading.

<aside>
  While quaternions were conceived as an extension to complex numbers, 
they have several practical applications, most notably in the area of 
navigation. A quaternion may be interpreted as a vector in three-dimensional 
(Euclidean) space along with a rotation about that vector.  

  This use of quaternions was first applied to resolution of the so-called 
Euler-angle singularity; a situation where the formula for motion of an 
object exhibits a singularity at a vertical angle of attack. This situation 
is sometimes called gimbal lock. Equations of motion developed using 
quaternions exhibit no such issues. In reality, the Euler-angle equations 
are NOT singular; they are indeterminate. Both the numerator and denominator 
approach zero at a vertical angle of attack. L'Hopital's rule is necessary 
to evaluate the equations at this input value. Such an approach is 
cumbersome, however, and quaternions provide a cleaner and more efficient 
solution. 

  Quaternions are also used in inverse kinematics (IK) to model the motion 
of bone chains. Quaternions avoid 'breaking' or 'popping' that was prevalent 
in early 3D software packages that resolved IK motion using Euler-angle 
models.
</aside>
Enter fullscreen mode Exit fullscreen mode

The Application

The application covered in this series is an abbreviated learning module involving quaternions and quaternion arithmetic. It consists of a login screen, a calculator that allows students to practice quaternion-arithmetic formulas, and an assessment test. An actual application might also include reading material on the topic, but that has been omitted for brevity. The general application flow is

1 — Login.

2 — Present student with the calculator for practice and option to take assessment test. The calculator is always displayed while the assessment test is optional.

3 — A test is scored after completion, and then results are displayed to student followed by sending the scored test to a server.

The tutorial series is divided into four parts, which might correspond to application sprints in practice:

Part I: Construct the global store by features using @ ngrx/store and implement the calculator. Login and test views are placeholders.

Part II: Complete the test view using @ ngrx/effects for retrieval of the assessment test and communication of scored results back to a server. Service calls are simulated using a mock back end.

Part III: Use @ ngrx/entity to model and work with test data in the application.

Part IV: Implement the login screen using simple authentication and illustrate concepts such as redirect url. This further introduces how to use @ ngrx/store in an environment similar to that you might encounter in actual work.

At present, stakeholders have prescribed that the student will always log in before being directed to the calculator practice view. As seasoned developers, we know that will change, so our plan is to work on the calculator first as it is the most complex view. The calculator also addresses the most complex slice of the global store.

Before continuing, you may wish to follow along or fork the Github for the application (in its Part I state).

TheAlgorithmist/intro-ngrx on GitHub

Models

Before we can construct a global store, it is necessary to understand models required by each feature in the application. Following is an outline of each feature’s data requirements as initially presented. Only the calculator requirement is believed to be solid as of this article.

User Model: first name, last name, class id, student id, and whether or not the student is authenticated to use this application.

Calculator Model: Quaternion and calculator models.

Test Model: Test id, string question, quaternion values for the correct answer and the student’s input.

The application also has a requirement that once a test has begun, the student may not interact with the calculator.

User model

The working User model at this point is

export interface User
{
  first: string;

  last: string;

  classID: string;

  studentID: string;

  authorized: boolean;
}
Enter fullscreen mode Exit fullscreen mode

There is also ‘talk’ about possibly echoing the user’s name back to them on a successful answer, i.e. ‘That’s correct. Great job, Sandeep!’ For present, we choose to make the entire user model a single slice of the global store.

Quaternion Model

For tutorial purposes, a quaternion consists of four numbers, w, i, j, and k. The student understands these to be the real part, and the amounts of the vector along the i, j, and k axes, respectively. As developers, we don’t care. It’s just four numbers, always provided in a pre-defined order. Based on past applications, I have supplied a class to organize this data, named after an infamous Star Trek TNG character :)

/src/app/shared/definitions/Q.ts

/**
 * Manage quaternion data
 *
 * @author Jim Armstrong
 *
 * @version 1.0
 */
export class Q
{
  public id  = '';

  protected _w = 0;
  protected _i = 0;
  protected _j = 0;
  protected _k = 0;

  /**
   * Construct a new Q
   *
   * @param wValue Real part of the quaternion
   *
   * @param iValue i-component of the quaternion
   *
   * @param jValue j-component of the quaternion
   *
   * @param kValue k-component of the quaternion
   *
   * @param _id (optional) id associated with these values
   */
  constructor(wValue: number, iValue: number, jValue: number, kValue: number, _id?: string)
  {
    this.w = wValue;
    this.i = iValue;
    this.j = jValue;
    this.k = kValue;

    if (_id !== undefined && _id != null && _id !== '') {
      this.id = _id;
    }
  }

  /**
   * Access the w-value of the quaternion
   */
  public get w(): number { return this._w; }

  /**
   * Assign the w-value of the quaternion
   *
   * @param {number} value
   */
  public set w(value: number)
  {
    if (!isNaN(value) && isFinite(value)) {
      this._w = value;
    }
  }

  /**
   * Access the i-value of the quaternion
   */
  public get i(): number { return this._i; }

  /**
   * Assign the i-value of the quaternion
   *
   * @param {number} value
   */
  public set i(value: number)
  {
    if (!isNaN(value) && isFinite(value)) {
      this._i = value;
    }
  }

  /**
   * Assign the i-value
   *
   * @param {number} value
   */
  public set i(value: number)
  {
    if (!isNaN(value) && isFinite(value)) {
      this._i = value;
    }
  }

  /**
   * Assign the k-value
   *
   * @param {number} value of the quaternion
   */
  public set j(value: number)
  {
    if (!isNaN(value) && isFinite(value)) {
      this._j = value;
    }
  }

  /**
   * Access the j-value of quaternion
   */
  public get j(): number { return this._j; }

  public get k(): number { return this._k; }

  /**
   * Assign the k-value
   *
   * @param {number} value
   */
  public set k(value: number)
  {
    if (!isNaN(value) && isFinite(value)) {
      this._k = value;
    }
  }

  /**
   * Clone this holder
   *
   * @returns {Q} Copy of current quaternion values holder
   */
  public clone(): Q
  {
    return new Q(this._w, this._i, this._j, this._k, this.id);
  }
}
Enter fullscreen mode Exit fullscreen mode

Calculator Model

The calculator consists of two input quaternions, a result quaternion, operation buttons for add/subtract/multiply/divide, and to/from memory buttons.

The state of the entire calculator is represented in /src/app/shared/definitions/QCalc.ts

/**
 * Model a section of the quaternion calculator store that pertains to all basic calculator actions
 *
 * @author Jim Armstrong (www.algorithmist.net)
 *
 * @version 1.0
 */
import { Q } from './Q';

export class QCalc
{
  public q1: Q;
  public q2: Q;
  public result: Q;
  public memory: Q | null;
  public op: string;

  constructor()
  {
    this.q1     = new Q(0, 0, 0, 0);
    this.q2     = new Q(0, 0, 0, 0);
    this.result = new Q(0, 0, 0, 0);
    this.memory = null;
    this.op     = 'none';
  }

  /**
   * Clone this container
   */
  public clone(): QCalc
  {
    const q: QCalc = new QCalc();

    q.q1     = this.q1.clone();
    q.q2     = this.q2.clone();
    q.result = this.result.clone();
    q.op     = this.op;
    q.memory = this.memory ? this.memory.clone() : null;

    return q;
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Model

The test section of the application is only a placeholder in Part I of this series. The test is not formally modeled at this time.

After examining these models, it seems that the application store consists of three slices, user, calculator, and test, where the latter slice is optional as the student is not required to take the test until they are ready.

These slices are currently represented in /src/app/shared/calculator-state.ts

import { User  } from './definitions/user';
import { QCalc } from './definitions/QCalc';
export interface CalcState
{
  user: User;
  calc: QCalc;
  test?: any;
}
Enter fullscreen mode Exit fullscreen mode

Features

The application divides nicely into three views or features, namely login, practice with calculator, and assessment test. These can each be represented by a feature module in the application. Each feature also contributes something to the global store.

The login screen contributes the user slice. The ‘practice with calculator’ view contributes the QCalc or calculator slice of the store. The assessment test contributes the test slice of the global store.

A feature of @ ngrx/store version 10 is that the global store need not be defined in its entirety in the main app module. The store may be dynamically constructed as features are loaded into the application.

The /src/app/features folder contains a single folder for each feature module of the application. Before deconstructing each feature, let’s look at the high-level application structure in /src/app/app.module.ts,

/**
 * Main App module for the quaternion application (currently at Part I)
 *
 * @author Jim Armstrong
 *
 * @version 1.0
 */
import { BrowserModule           } from '@angular/platform-browser';
import { NgModule                } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { StoreModule } from '@ngrx/store';

import { MatTabsModule      } from '@angular/material/tabs';

import { AppRoutingModule   } from './app-routing.module';
import { LoginModule        } from './features/login-page/login.module';
import { CalculatorModule   } from './features/quaternion-calculator/calculator.module';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    AppRoutingModule,
    MatTabsModule,
    StoreModule.forRoot({}),
    LoginModule,
    CalculatorModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Notice that unlike other @ ngrx/store tutorials you may have seen in the past, the global store is empty,

StoreModule.forRoot({}),

In past examples of using @ ngrx/store for just the quaternion calculator, I defined the reducers for each slice,

import { QInputs } from "./QInputs";
import { QMemory } from "./QMemory";

export interface CalcState
{
  inputs: QInputs;

  memory: QMemory;
}

import { ActionReducerMap } from '@ ngrx/store';
import {inputReducer, memoryReducer} from "../reducers/quaternion.reducers";

export const quaternionCalcReducers: ActionReducerMap<CalcState> =
{
  inputs: inputReducer,
  memory: memoryReducer
};
Enter fullscreen mode Exit fullscreen mode

and then imported quaternionCalcReducers into the main app module, followed by

@NgModule({
  declarations: APP_DECLARATIONS,
  imports: [
    PLATFORM_IMPORTS,
    MATERIAL_IMPORTS,
    StoreModule.forRoot(quaternionCalcReducers)
  ],
  providers: APP_SERVICES,
  bootstrap: [AppComponent]
})
Enter fullscreen mode Exit fullscreen mode

The current application begins with an empty store. The application’s features build up the remainder of the store as they are loaded.

And, on the subject of loading, here is the main app routing module,

import { NgModule } from '@angular/core';
import {
  Routes,
  RouterModule
} from '@angular/router';

import { CalculatorComponent } from './features/quaternion-calculator/calculator/calculator.component';
import { LoginComponent      } from './features/login-page/login/login.component';

const calculatorRoutes: Routes = [
  { path: 'calculator', component: CalculatorComponent},

  { path: 'login', component: LoginComponent},

  { path: 'test',  loadChildren: () => import('./features/test/test.module').then(m => m.TestModule)},

  { path: '', redirectTo: 'calculator', pathMatch: 'full'},
];

@NgModule({
  imports: [
    RouterModule.forRoot(calculatorRoutes)
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }
Enter fullscreen mode Exit fullscreen mode

Part I of this tutorial simulates a realistic situation where we don’t have a full, signed-off set of specifications for login and we may not even have complete designs. Login is deferred until a later sprint and the application currently displays the calculator by default. Note that the calculator is always available to the student when the application loads.

The test is always optional, so the test module is lazy-loaded.

Our deconstruction begins with the login feature.

Login Feature (/src/app/features/login)

This folder contains a login-page folder for the Angular Version 10 login component as well as the following files:

  • login.actions.ts (actions for the login feature)
  • login.module.ts (Angular feature model for login)
  • login.reducer.ts (reducer for the login feature)

Unlike applications or tutorials you may have worked on in the past, a feature module may now contain store information, component, and routing definitions.

My personal preference is to consider development in the order of actions, reducers, and then module definition.

Login actions

These actions are specified in /src/app/features/login-page/login.actions.ts,

import {
  createAction,
  props
} from '@ngrx/store';

import { User } from '../../shared/definitions/user';

export const Q_AUTH_USER = createAction(
  '[Calc] Authenticate User'
);

export const Q_USER_AUTHENTICATED = createAction(
  '[Calc] User Authenticated',
  props<{user: User}>()
);
Enter fullscreen mode Exit fullscreen mode

The expectation is that the username/password input at login are to be sent to an authentication service. That service returns a User object, part of which is a boolean to indicate whether or not that specific login is authorized for the application.

If you are not used to seeing props as shown above, this is the @ ngrx/store version 10 mechanism to specify metadata (payloads in the past) to help process the action. This approach provides better type safety, which I can appreciate as an absent-minded mathematician who has messed up a few payloads in my time :)

Login reducers

Reducers modify the global store in response to specific actions and payloads. Since the global store is constructed feature-by-feature, each feature module contains a feature key that is used to uniquely identify the slice of the global store covered by that feature.

The reducer file also defines an initial state for its slice of the store. This is illustrated in the very simple reducer from /src/app/features/login-page/login.reducer.ts,

import {
  createReducer,
  on
} from '@ngrx/store';

import * as LoginActions from './login.actions';

import { User } from '../../shared/definitions/user';

const initialLoginState: User = {
  first: '',
  last: '',
  classID: '101',
  studentID: '007',
  authorized: true
};

// Feature key
export const userFeatureKey = 'user';

export const loginReducer = createReducer(
  initialLoginState,

  on( LoginActions.Q_AUTHENTICATE_USER, (state, {user}) => ({...state, user}) ),
);
Enter fullscreen mode Exit fullscreen mode

Spread operators may be convenient, but always be a bit cautious about frequent use of shallow copies, especially when Typescript classes and more complex objects are involved. You will note that all my Typescript model classes contain clone() methods and frequent cloning is performed before payloads are even sent to a reducer. This can be helpful for situations where one developer works on a component and another works on reducers. Sloppy reducers can give rise to the infamous ‘can not modify private property’ error in an NgRx application.

Login feature module

The login component is eagerly loaded. The login route is already associated with a component in the main app routing module. The login feature module defines the slice of the global store that is created when the login module is loaded.

/src/app/features/login-page/login.module.ts

import { NgModule } from '@angular/core';

import { StoreModule } from '@ngrx/store';

import * as fromLogin from './login.reducer';

@NgModule({
  imports:
    [
      StoreModule.forFeature(fromLogin.userFeatureKey, fromLogin.loginReducer),
    ],
  exports: []
})
export class LoginModule {}
Enter fullscreen mode Exit fullscreen mode

Since LoginModule is imported into the main app module, the user slice of the global store is defined as soon as the application loads.

The test module, however, is lazy-loaded, so its implementation is slightly more involved.

Test Feature (/src/app/features/test)

This folder contains the test folder for the Angular component files as well as feature-related files. As with login, the feature-specific files are

  • test.actions.ts (actions for the test feature)
  • test.module.ts (Angular feature model for test)
  • test.reducer.ts (reducer for the login feature)

And, as before, these are deconstructed in the order, actions, reducers, and then feature module.

Test Actions

As of Part I of this tutorial, we anticipate four test actions,

1 — Request a list of test questions from a server (Q_GET_TEST)

2 — Indicate that the test has begun (Q_BEGIN_TEST)

3 — Send a collection of scored test results back to the server (Q_SCORE_TEST)

4 — Send test results back to the server (Q_SEND_TEST_RESULTS)

The second action is needed to ensure that the calculator can not be used once the test begins.

/src/app/features/test/test.actions.ts

import {
  createAction,
  props
} from '@ngrx/store';

// Feature key
export const textFeatureKey = 'test';

export const Q_GET_TEST = createAction(
  '[Calc] Get Test'
);

export const Q_BEGIN_TEST = createAction(
  '[Calc] Begin Test',
  props<{startTime: Date}>()
);

export const Q_SCORE_TEST = createAction(
  '[Calc] Score Test',
  props<{results: Array<any>}>()
);

export const Q_SEND_TEST_RESULTS = createAction(
  '[Calc] Send Test Results',
  props<{endTime: Date, results: Array<any>}>()
);
Enter fullscreen mode Exit fullscreen mode

A feature key is again used as a unique identifier for the test slice of the global store. Part I of this tutorial simulates a situation where we have not been given the model for a collection of test questions. Nor do we understand how to extend that model to include scored results. Typings applied to the payload for the final two actions are simply placeholders.

<hint>
  Stories typically have unique identifiers in tracking systems.  Consider 
using the tracking id as part of the action name. In the case of Pivotal 
Tracker, for example, 'ADD [PT 10472002]'. This string contains the 
operation, i.e. 'ADD', along with the Pivotal Tracker ID for the story. 
This allows other developers to quickly relate actions to application 
requirements.
</hint>
Enter fullscreen mode Exit fullscreen mode

Test Reducers

The current test reducer and initial test state are placeholders for Part I of this tutorial.

/src/app/features/test/test.reducer.ts

import * as TestActions from './test.actions';

import {
  createReducer,
  on
} from '@ngrx/store';

// At Part I, we don't yet know the model for a test question
const initialTestState: {test: Array<string>} = {
  test: new Array<any>()
};

// Feature key
export const testFeatureKey = 'test';

const onGetTest = on (TestActions.Q_GET_TEST, (state) => {
  // placeholder - currently does nothing
  return { state };
});

export const testReducer = createReducer(
  initialTestState,
  onGetTest
);
Enter fullscreen mode Exit fullscreen mode

Test Module

The test module defines routes and adds the test slice to the global store,

/src/app/features/test/test.module.ts

import { NgModule     } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  RouterModule,
  Routes
} from '@angular/router';

import { StoreModule } from '@ngrx/store';

import * as fromTest from './test.reducer';

import { TestComponent } from './test/test.component';

import { AuthGuard } from '../../shared/guards/auth-guard';

const routes: Routes = [
  { path: '', component: TestComponent, canActivate: [AuthGuard] }
];

@NgModule({
  declarations: [
    TestComponent
  ],
  imports:
    [
      CommonModule,
      StoreModule.forFeature(fromTest.testFeatureKey, fromTest.testReducer),
      RouterModule.forChild(routes)
    ],
  providers: [AuthGuard],
  exports: [
  ]
})
export class TestModule {}
Enter fullscreen mode Exit fullscreen mode

Notice that a route guard has been added to the default child route. This guard ensures that the test route may not be directly requested unless the user is currently authorized. The guard will be fully implemented in part IV of this tutorial. The current implementation simply hardcodes an authenticated flag, so that any user is considered authorized.

Calculator Feature (/src/app/features/quaternion-calculator)

The calculator is the main focus of Part I of this tutorial, so its action list is complete,

/src/app/features/quaternion-calculator/calculator.actions.ts

import {
  createAction,
  props
} from '@ngrx/store';


import { Q } from '../../shared/definitions/Q';

// Actions
export const Q_UPDATE = createAction(
  '[Calc] Update',
  props<{id: string, q: Q}>()
);

export const Q_ADD = createAction(
  '[Calc] Add',
  props<{q1: Q, q2: Q}>()
);

export const Q_SUBTRACT = createAction(
  '[Calc] Subtract',
  props<{q1: Q, q2: Q}>()
);

export const Q_MULTIPLY = createAction(
  '[Calc] Multiply',
  props<{q1: Q, q2: Q}>()
);

export const Q_DIVIDE = createAction(
  '[Calc] Divide',
  props<{q1: Q, q2: Q}>()
);

export const Q_CLEAR = createAction(
  '[Calc] Clear',
);

export const TO_MEMORY = createAction(
  '[Calc] To_Memory',
  props<{q: Q, id: string}>()
);

export const FROM_MEMORY = createAction(
  '[Calc] From_Memory',
  props<{id: string}>()
);
Enter fullscreen mode Exit fullscreen mode

Note that all payloads involving quaternions use the generic ‘Q’ class. This allows the reducer the greatest flexibility in implementing calculator operations. Before we look at the reducer, though, recall that the Typescript Math Toookit TSMT$Quaternion class is used to implement all quaternion arithmetic. In the future, though, a different class (or collection of pure functions) might be used.

With future changes in mind, the Adapter Pattern is applied to create an intermediary between the generic ‘Q’ structure and the code responsible for quaternion arithmetic. This helper class is located in /src/app/shared/libs/QCalculations.ts

import { TSMT$Quaternion } from './Quaternion';
import { Q              } from '../definitions/Q';

export class QCalculations
{
  protected static readonly Q1: TSMT$Quaternion = new TSMT$Quaternion();
  protected static readonly Q2: TSMT$Quaternion = new TSMT$Quaternion();

  constructor()
  {
    // empty
  }

  /**
   * Add two quaternions
   *
   * @param q1 4-tuple representing first input quaternion
   *
   * @param q2 4=tuple representing second input quaternion
   */
  public static add(q1: Q, q2: Q): Q
  {
    QCalculations.Q1.fromArray(q1.w, q1.i, q1.j, q1.k);
    QCalculations.Q2.fromArray(q2.w, q2.i, q2.j, q2.k);

    QCalculations.Q1.add(QCalculations.Q2);

    const values: Array<number> = QCalculations.Q1.toArray();

    return new Q(values[0], values[1], values[2], values[3]);
  }

  /**
   * Subtract two quaternions
   *
   * @param q1 4-tuple representing first input quaternion
   *
   * @param q2 4=tuple representing second input quaternion
   */
  public static subtract(q1: Q, q2: Q): Q
  {
    QCalculations.Q1.fromArray(q1.w, q1.i, q1.j, q1.k);
    QCalculations.Q2.fromArray(q2.w, q2.i, q2.j, q2.k);

    QCalculations.Q1.subtract(QCalculations.Q2);

    const values: Array<number> = QCalculations.Q1.toArray();

    return new Q(values[0], values[1], values[2], values[3]);
  }

  /**
   * Mutiply two quaternions
   *
   * @param q1 4-tuple representing first input quaternion
   *
   * @param q2 4=tuple representing second input quaternion
   */
  public static multiply(q1: Q, q2: Q): Q
  {
    QCalculations.Q1.fromArray(q1.w, q1.i, q1.j, q1.k);
    QCalculations.Q2.fromArray(q2.w, q2.i, q2.j, q2.k);

    QCalculations.Q1.multiply(QCalculations.Q2);

    const values: Array<number> = QCalculations.Q1.toArray();

    return new Q(values[0], values[1], values[2], values[3]);
  }

  /**
   * Divide two quaternions
   *
   * @param q1 4-tuple representing first input quaternion
   *
   * @param q2 4=tuple representing second input quaternion
   */
  public static divide(q1: Q, q2: Q): Q
  {
    QCalculations.Q1.fromArray(q1.w, q1.i, q1.j, q1.k);
    QCalculations.Q2.fromArray(q2.w, q2.i, q2.j, q2.k);

    QCalculations.Q1.divide(QCalculations.Q2);

    const values: Array<number> = QCalculations.Q1.toArray();

    return new Q(values[0], values[1], values[2], values[3]);
  }
}
Enter fullscreen mode Exit fullscreen mode

This class currently uses TSMT$Quaternion for quaternion arithmetic. If another library is used in the future, it is not necessary to change reducer code; only the helper class need be modified. This helper or adapter class can also have its own set of tests, which serves to strengthen tests already present for reducers.

Now, we can deconstruct the calculator reducers. The createReducer() method from @ ngrx/store seems so simple with one-line reducers in a scoreboard or counter application. The quaternion calculator is different in that reduction for each calculator operation is more involved.

import {
  createReducer,
  on,
  createSelector,
  createFeatureSelector
} from '@ngrx/store';

import * as CalculatorActions from './calculator.actions';

import { QCalc         } from '../../shared/definitions/QCalc';
import { QCalculations } from '../../shared/libs/QCalculations';
import { Q             } from '../../shared/definitions/Q';
import { CalcState     } from '../../shared/calculator-state';

const initialCalcState: {calc: QCalc} = {
  calc: new QCalc()
};

function calcFatory(calculator: QCalc, q1: Q, q2: Q, result: Q): QCalc
{
  const newCalculator: QCalc = new QCalc();

  newCalculator.q1     = q1.clone();
  newCalculator.q2     = q2.clone();
  newCalculator.result = result.clone();
  newCalculator.op     = calculator.op;
  newCalculator.memory = calculator.memory ? calculator.memory : null;

  return newCalculator;
}

// Feature key
export const calculatorFeatureKey = 'calc';

// Selectors
export const getCalcState = createFeatureSelector<CalcState>(calculatorFeatureKey);

export const getCalculator = createSelector(
  getCalcState,
  (state: CalcState) => state ? state.calc : null
);

// Calculator Reducers
const onUpdate = on (CalculatorActions.Q_UPDATE, (state, {id, q}) => {
  const calculator: CalcState = state as CalcState;

  const newCalculator: QCalc = calculator.calc.clone();

  if (id === 'q1')
  {
    // update first quaternion
    newCalculator.q1 = q.clone();
  }
  else
  {
    // update second quaternion
    newCalculator.q2 = q.clone();
  }

  return { ...calculator.user, calc: newCalculator };
});

const onAdd = on (CalculatorActions.Q_ADD, (state, {q1, q2}) => {
  const calculator: CalcState = state as CalcState;

  const q: Q = QCalculations.add(q1, q2);

  return { ...calculator.user, calc: calcFatory(calculator.calc, q1, q2, q) };
});

const onSubtract = on (CalculatorActions.Q_SUBTRACT, (state, {q1, q2}) => {
  const calculator: CalcState = state as CalcState;

  const q: Q = QCalculations.subtract(q1, q2);

  return { ...calculator.user, calc: calcFatory(calculator.calc, q1, q2, q) };
});

const onMultiply = on (CalculatorActions.Q_MULTIPLY, (state, {q1, q2}) => {
  const calculator: CalcState = state as CalcState;

  const q: Q = QCalculations.multiply(q1, q2);

  return { ...calculator.user, calc: calcFatory(calculator.calc, q1, q2, q) };
});

const onDivide = on (CalculatorActions.Q_DIVIDE, (state, {q1, q2}) => {
  const calculator: CalcState = state as CalcState;

  const q: Q = QCalculations.divide(q1, q2);

  return { ...calculator.user, calc: calcFatory(calculator.calc, q1, q2, q) };
});

const onToMemory = on (CalculatorActions.TO_MEMORY, (state, {q}) => {
  const calculator: CalcState = state as CalcState;

  const newCalculator  = calculator.calc.clone();
  newCalculator.memory = q.clone();

  return { ...calculator.user, calc: newCalculator };
});

const onFromMemory = on (CalculatorActions.FROM_MEMORY, (state, {id}) => {
  const calculator: CalcState = state as CalcState;

  const newCalculator  = calculator.calc.clone();

  switch (id)
  {
    case 'Q_1':
      newCalculator.q1 = newCalculator.memory != null ? newCalculator.memory.clone() : null;
      break;

    case 'Q_2':
      newCalculator.q2 = newCalculator.memory != null ? newCalculator.memory.clone() : null;
      break;

    default:
      // no action taken at this time as index is invalid; perhaps throw an error
  }

  return { ...calculator.user, calc: newCalculator };
});

const onClear = on (CalculatorActions.Q_CLEAR, (state) => {
  const calculator: CalcState = state as CalcState;

  return { ...calculator.user, calc: new QCalc() };
});

export const calculatorReducer = createReducer(
  initialCalcState,
  onUpdate,
  onAdd,
  onSubtract,
  onMultiply,
  onDivide,
  onToMemory,
  onFromMemory,
  onClear
);
Enter fullscreen mode Exit fullscreen mode

Let’s look at one action, calculator addition. The second argument to the @ ngrx/store on() method is the combination of prior store and payload. The payload shape is described in the action, so examine the action and reducer side-by-side:

export const Q_ADD = createAction(
  '[Calc] Add',
  props<{q1: Q, q2: Q}>()
);
.
.
.
const onAdd = on (CalculatorActions.Q_ADD, (state, **{q1, q2}**) => {
  const calculator: CalcState = state as CalcState;

  const q: Q = QCalculations.add(q1, q2);

  return { ...calculator.user, calc: calcFatory(calculator.calc, q1, q2, q) };
});
Enter fullscreen mode Exit fullscreen mode

Other calculation computations are handled in a similar manner. Note that an id is involved in moving quaternion data to and from calculator memory and this id is specified in the quaternion calculator template,

/src/app/features/quaternion-calculator/calculator/calculator.component.html

.
.
.
<div class="card-center">
  <app-quaternion id="q1" [inputDisabled]="inputDisabled" (qChanged)="onQuaternionChanged($event)"></app-quaternion>
</div>
<app-memory id="Q_1" (memTo)="onToMemory($event)" (memFrom)="onFromMemory($event)"></app-memory>
.
.
.
Enter fullscreen mode Exit fullscreen mode

Recall that the QCalc class is used to represent the calculator slice of the global store, so initial calculator state is simply a new instance of this class,

const initialCalcState: {calc: QCalc} = {
  calc: new QCalc()
};
Enter fullscreen mode Exit fullscreen mode

and, the reducer for all calculator actions is defined at the end of the process,

export const calculatorReducer = createReducer(
  initialCalcState,
  onUpdate,
  onAdd,
  onSubtract,
  onMultiply,
  onDivide,
  onToMemory,
  onFromMemory,
  onClear
);
Enter fullscreen mode Exit fullscreen mode

The calculator route is eagerly loaded and already specified in the main app routing module, so the calculator module only handles adding the calculator section or slice to the global store,

/src/app/features/quaternion-calculator/calculator.module.ts

.
.
.

@NgModule({
  declarations: [
    CalculatorComponent,
    QuaternionComponent,
    MemoryComponent,
    ResultComponent,
  ],
  imports:
    [
      CommonModule,
      FormsModule,
      MAT_IMPORTS,
      StoreModule.forFeature(fromCalculator.calculatorFeatureKey, fromCalculator.calculatorReducer),
    ],
  exports: [
  ]
})
export class CalculatorModule {}
Enter fullscreen mode Exit fullscreen mode

This process seems intimidating at first, but only if you try to absorb everything at one time. I personally like the build-the-store-by-feature approach illustrated above, as it’s very intuitive. Remember the order actions, reducers, module, and try working on just one action and one reducer function at a time. That’s exactly what I did when preparing this tutorial. I worked on the ADD action first. Then, I implemented SUBTRACT. I noticed some repeated code and made the reducers more DRY. Then, the remainder of the calculator reducers came together in short order.

Store Selection

Components query the store (or some subset) and generally reflect those values directly into the component’s template. This application is different in that some components follow that exact model while others such as the calculator maintain an internal copy of the calc slice of the store. That component’s template does not directly reflect any of the calc values. It maintains a constant sync with the ‘q1’ and ‘q2’ input quaternions in order to dispatch copies of them as payloads when the user clicks on one of the operations (add/subtract/multiply/divide).

@ ngrx/store provides the ability to direct-select a named slice from the store and assign the result to an Observable. This feature is illustrated in the counter app in the @ ngrx/store docs.

Store selectors may also be created, which direct-select exact slices of the store or subsets of those slices. This process is illustrated in the calculator reducer file, /src/app/features/quaternion-calculator/calculator.reducer.ts,

.
.
.
export const getCalcState = createFeatureSelector<CalcState>(calculatorFeatureKey);

export const getCalculator = createSelector(
  getCalcState,
  (state: CalcState) => state ? state.calc : null
);

// Select result quaternion values - combine these as an exercise
export const getResultW = createSelector(
  getCalcState,
  (state: CalcState) => state ? (state.calc.result ? state.calc.result.w : null) : null
);

export const getResultI = ((createSelector(((
  getCalcState,
  (state: CalcState) => state ? (state.calc.result ? state.calc.result.i : null) : null
);

export const getResultJ = createSelector(
  getCalcState,
  (state: CalcState) => state ? (state.calc.result ? state.calc.result.j : null) : null
);

export const getResultK = createSelector(
  getCalcState,
  (state: CalcState) => state ? (state.calc.result ? state.calc.result.k : null) : null
);
Enter fullscreen mode Exit fullscreen mode

One selector fetches the calc state of the global store while the remaining four selectors query the individual values of the result quaternion.

A classic subscription model is used to handle updates from the store inside the calculator component,

/src/app/features/quaternion-calculator/calculator/calculator.component.ts

protected _calc$: Subject<boolean>;
.
.
.
this._store.pipe(
  select(getCalculator),
  takeUntil(this._calc$)
)
.subscribe( calc => this.__onCalcChanged(calc));
Enter fullscreen mode Exit fullscreen mode

The _onCalcChanged() method simply syncs the class variable with the store,

protected __onCalcChanged(calc: QCalc): void
{
  if (calc) {
    this._qCalc = calc.clone();
  }
}
Enter fullscreen mode Exit fullscreen mode

and the unsubscribe is handled in the on-destroy lifecycle hander,

public ngOnDestroy(): void
{
  this._calc$.next(true);
  this._calc$.complete();
}
Enter fullscreen mode Exit fullscreen mode

Next, look at the result quaternion code in /src/app/shared/components/result/result.component.ts

The result quaternion values [w, i, j, k] are directly reflected in the template and can be easily updated with the just-created selectors and an async pipe.

.
.
.
import {
  getResultW,
  getResultI,
  getResultJ,
  getResultK
} from '../../../features/quaternion-calculator/calculator.reducer';

@Component({
  selector: 'app-result',

  templateUrl: './result.component.html',

  styleUrls: ['./result.component.scss']
})
export class ResultComponent
{
  // Observables of quaternion values that are directly reflected in the template
  public w$: Observable<number>;
  public i$: Observable<number>;
  public j$: Observable<number>;
  public k$: Observable<number>;

  constructor(protected _store: Store<CalcState>)
  {
    this.w$ = this._store.pipe( select(getResultW) );
    this.i$ = this._store.pipe( select(getResultI) );
    this.j$ = this._store.pipe( select(getResultJ) );
    this.k$ = this._store.pipe( select(getResultK) );
  }
}
Enter fullscreen mode Exit fullscreen mode

/src/app/shared/components/result/result.component.html,

<div>
  <mat-form-field class="qInput">
    <input matInput type="number" value="{{w$ | async}}" readonly />
  </mat-form-field>

  <mat-form-field class="qInput qSpaceLeft">
    <input matInput type="number" value="{{i$ | async}}" readonly />
  </mat-form-field>

  <mat-form-field class="qInput qSpaceLeft">
    <input matInput type="number" value="{{j$ | async}}" readonly />
  </mat-form-field>

  <mat-form-field class="qInput qSpaceLeft">
    <input matInput type="number" value="{{k$ | async}}" readonly />
  </mat-form-field>
</div>
Enter fullscreen mode Exit fullscreen mode

Result

This is the initial view for Part I after building the application.

Screenshot of an online quiz. There are two tabs to the quiz, one opened one closed. The opened tab is labeled "Practice With Calculator", the closed tab is labeled "Assessment Test". The test in the opened tab is named "Quaternion Calculator".

Quaternion Application Initial View

Now, if you were expecting great design from a mathematician, then you probably deserve to be disappointed :)

Experiment with quaternion arithmetic and have fun. Be warned, however, multiplication and division are not what you might expect.

Summary

Applications are rarely built all at once. They are often created small sections at a time (usually in organized sprints). Not everything will be defined in full detail at the onset of a project, so the global store may evolve over time. I hope this tutorial series introduces the NgRx suite in a manner that is less like other tutorials and more like how you would use the framework in a complete application.

In Part II, we receive the test definition from the back-end team and a proposal for a set of service calls to implement the test view. We will mock a back end using an HTTP Interceptor and fill out the test slice of the global store. @ ngrx/effects will be used to handle service interactions.

I hope you found something helpful from this tutorial and best of luck with your Angular efforts!

ng-conf: The Musical is coming

ng-conf: The Musical is a two-day conference from the ng-conf folks coming on April 22nd & 23rd, 2021. Check it out at ng-conf.org

Thanks to Michi DeWitt.

Discussion (0)

pic
Editor guide