Introduction to the NGRX Suite, Part 1
ng-conf
Posted on April 9, 2021
Jim Armstrong | ng-conf | Oct 2020
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>
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;
}
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);
}
}
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;
}
}
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;
}
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 { }
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
};
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]
})
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 { }
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}>()
);
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}) ),
);
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 {}
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>}>()
);
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>
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
);
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 {}
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}>()
);
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]);
}
}
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
);
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) };
});
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>
.
.
.
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()
};
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
);
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 {}
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
);
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));
The _onCalcChanged() method simply syncs the class variable with the store,
protected __onCalcChanged(calc: QCalc): void
{
if (calc) {
this._qCalc = calc.clone();
}
}
and the unsubscribe is handled in the on-destroy lifecycle hander,
public ngOnDestroy(): void
{
this._calc$.next(true);
this._calc$.complete();
}
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) );
}
}
/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>
Result
This is the initial view for Part I after building the application.
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.
Posted on April 9, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 10, 2024
August 13, 2024
July 2, 2024
June 11, 2024