Angular - A Development Pattern
Learn about an FSM-based state transitions technique for developing robust Angular web applications.
Join the DZone community and get the full member experience.
Join For FreeThis article proposes a development pattern for Angular applications (SPAs). The pattern uses an FSM-based state transitions technique. The benefits of this pattern are discusssed at the end of the article.
A Sample App
The sample app considered for illustrating the pattern has views like those shown here where the user can perform the following actions:
- View a list of products
- View the product details
- Add a product to the cart
- View the cart items count
- View the cart items
- If Admin, can add one or more products
Pattern Steps
The pattern suggests the following steps.
1. Capture the State Transitions
The pattern suggests capturing the SPA's requirements into a set of state transitions. For the views in the sample app, the following state transitions are considered:
Initial State | Pre-Event | Process | Post-Event | Final State |
DEFAULT | onload | processOnload() | onload_success | ONLOADSUCCESSVIEW |
ONLOADSUCCESS | get_products | processGetProducts() | get_products_success | PRODUCTSVIEW |
PRODUCTSVIEW | get_product_details | processGetProductDetails() | get_product_details_success | PRODUCTDETAILSVIEW |
PRODUCTDETAILSVIEW | add_to_cart | processAddToCart() | add_to_cart_success | ADDTOCARTSUCCESSVIEW |
ADDTOCARTSUCCESSVIEW | update_cartcount | processUpdateCartCount() | update_cart_count_success | UPDATACARTCOUNTSUCCESSVIEW |
UPDATECARTCOUNTSUCCESSVIEW | get_cart | processGetCart() | get_cart_success | CARTVIEW |
CARTVIEW | get_product_details | processGetProductDetails() | get_product_details_success | PRODUCTDETAILSFORCARTVIEW |
PRODUCTSVIEW | add_product_form | processAddProductForm() | add_product_form_succcess | ADDPRODUCTFORMSUCCESSVIEW |
ADDPRODUCTFORMSUCCESSVIEW | add_product | processAddProduct() | add_product_succcess | ADDPRODUCTSUCCESSVIEW |
Please note that the error conditions like add_product_error, etc. are not considered in the above list but can be easily added as additional state transitions where needed. The pre-events in the above list are user-initiated except update_cartcount, which is an internal system event.
2. Configure the Events, View States, and Transitions
After generating the base application code using the Angular CLI command, the following configuration classes are added.
The events and view states are configured in TypeScript enums like app-events.enum.ts
:
x
export enum AppEvent {
onload = "onload",
onload_success = "onload_success",
get_products = "get_products",
get_products_success = "get_products_success",
get_product_details = "get_product_details",
get_product_details_success = "get_product_details_success",
add_to_cart = "add_to_cart",
add_to_cart_success = "add_to_cart_success",
update_cartcount = "update_cartcount",
update_cartcount_success = "update_cartcount_success",
get_cart = "get_cart",
get_cart_success = "get_cart_success",
add_product = "add_product",
add_product_success = "add_product_success",
add_product_form = "add_product_form",
add_product_form_success = "add_product_form_success",
}
view-states.enum.ts
:
x
export enum AppViewState {
DEFAULT = "DEFAULT",
ONLOADSUCCESSVIEW = "ONLOADSUCCESSVIEW",
PRODUCTSVIEW = "PRODUCTSVIEW",
PRODUCTDETAILSVIEW = "PRODUCTDETAILSVIEW",
ADDTOCARTSUCCESSVIEW = "ADDTOCARTSUCCESSVIEW",
UPDATECARTCOUNTSUCCESSVIEW = "UPDATECARTCOUNTSUCCESSVIEW",
CARTVIEW = "CARTVIEW",
PRODUCTDETAILSFORCARTVIEW = "PRODUCTDETAILSFORCARTVIEW",
ADDPRODUCTFORMSUCCESSVIEW = "ADDPRODUCTFORMSUCCESSVIEW",
ADDPRODUCTSUCCESSVIEW = "ADDPRODUCTSUCCESSVIEW"
}
And the state transitions are configured as const variables like state-transitions.ts
:
x
/**
DEFAULT -> onload -> processOnload() -> onload_succcess -> ONLOADSUCCESSVIEW
ONLOADSUCCESSVIEW -> get_products -> processGetProducts() -> get_products_succcess -> PRODUCTSVIEW
PRODUCTSVIEW -> get_product_details -> processGetProductDetails() -> get_product_details_success -> PRODUCTDETAILSVIEW
PRODUCTDETAILSVIEW -> add_to_cart -> processAddToCart() -> add_to_cart_success -> ADDTOCARTSUCCESSVIEW
ADDTOCARTSUCCESSVIEW -> update_cartcount -> processUpdateCartCount() -> update_cartcount_success -> UPDATECARTCOUNTSUCCESSVIEW
UPDATECARTCOUNTSUCCESSVIEW-> get_cart -> processGetCart() -> get_cart_success -> CARTVIEW
CARTVIEW -> get_product_details -> processGetProductDetails() -> get_product_details_success -> PRODUCTDETAILSFORCARTVIEW
PRODUCTSVIEW -> add_product_form -> processAddProductForm() -> add_product_form_succcess -> ADDPRODUCTFORMSUCCESSVIEW
ADDPRODUCTFORMSUCCESSVIEW -> add_product -> processAddProduct() -> add_product_succcess -> ADDPRODUCTSUCCESSVIEW
*/
import { first } from 'rxjs/operators';
import { AppDataStore } from './app-data.store'
import { AppEvent } from './app-events.enum';
import { StateTransitionData } from './state-transitions.model';
import { AppRole, User } from './user.model';
import { AppViewState } from './view-states.enum';
export const AppPreEvents = {
onload: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
if (stData.user) {
stData.postEvent = AppEvent.onload_success;
}
else {
//TODO: handle auth error
}
return stData;
}
},
get_products: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
appDataStore.loadProducts();
stData.postEvent = AppEvent.get_products_success;
return stData;
}
},
get_product_details: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
appDataStore.loadProduct(stData.productId);
stData.postEvent = AppEvent.get_product_details_success;
return stData;
}
},
add_product_form: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
if (stData.user.appRoles.includes(AppRole.ADMIN)) {
stData.postEvent = AppEvent.add_product_form_success;
}
else {
//TODO: handle authorization error
}
return stData;
}
},
add_product: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
if (stData.user.appRoles.includes(AppRole.ADMIN)) {
stData.product = appDataStore.addProduct(stData.product);
stData.postEvent = AppEvent.add_product_success;
}
else {
//TODO: handle authorization error
}
return stData;
}
},
add_to_cart: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
appDataStore.addToCart(stData.productId);
stData.postEvent = AppEvent.add_to_cart_success;
return stData;
}
},
update_cartcount: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.postEvent = AppEvent.update_cartcount_success;
return stData;
}
},
get_cart: {
process: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.postEvent = AppEvent.get_cart_success;
return stData;
}
},
}
export const AppPostEvents = {
onload_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.ONLOADSUCCESSVIEW;
return stData;
}
},
get_products_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.PRODUCTSVIEW;
return stData;
}
},
get_product_details_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
if (stData.initState === AppViewState.PRODUCTSVIEW) {
stData.finalState = AppViewState.PRODUCTDETAILSVIEW;
}
else if (stData.initState === AppViewState.CARTVIEW) {
stData.finalState = AppViewState.PRODUCTDETAILSFORCARTVIEW;
}
return stData;
}
},
add_to_cart_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.ADDTOCARTSUCCESSVIEW;
return stData;
}
},
update_cartcount_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.UPDATECARTCOUNTSUCCESSVIEW;
return stData;
}
},
get_cart_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.CARTVIEW;
return stData;
}
},
add_product_form_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.ADDPRODUCTFORMSUCCESSVIEW;
return stData;
}
},
add_product_success: {
nextState: function (stData: StateTransitionData, appDataStore: AppDataStore): StateTransitionData {
stData.finalState = AppViewState.ADDPRODUCTSUCCESSVIEW;
return stData;
}
}
}
export function doTransition(appDataStore: AppDataStore, stData: StateTransitionData): StateTransitionData {
appDataStore.state$.pipe(first(),).subscribe(ad => {
stData.user = ad.user;
});
stData = AppPreEvents[stData.preEvent].process(stData, appDataStore);
stData = AppPostEvents[stData.postEvent].nextState(stData, appDataStore);
return stData;
}
All the core functionalities of the pattern are implemented in the state-transitions.ts
file. The const variable AppPreEvents
configures the process function for each pre-event. The process functions prepare the data needed by each component by calling the methods in AppDataStore
, which is a central datastore for the whole app. The process methods, when complete, raise post-events which in turn are handled by the AppPostEvents
to return the final state. This demo is currently using the RxJS based framework rxjs-observable-store. This can be easily replaced with NgRx or similar frameworks. The StateTransitionData
is a model class. The doTransition()
is a utility function to call the process methods. The state-transitions.ts
file also has the guard conditions like whether the uer is logged in or whether the user is authorized to access the process method, etc.
app-data.store.ts
xxxxxxxxxx
import { Injectable } from '@angular/core';
import { Store } from 'rxjs-observable-store';
import { AppData } from './state-transitions.model';
import { ProductsService } from '../product/products.service';
import { AppViewState } from './view-states.enum';
import { Product } from '../product/product.model';
import { UserService } from './user.service';
import { User } from './user.model';
import { first } from 'rxjs/operators';
/**
* This class will serve as a central data store for all the data used in the application.
* This class allso manages when to persist/refresh data to/from the backend,
* and when to persist/refresh from localStorage etc.
*
* This class is currently using the [rxjs-observable-store]{@link https://github.com/georgebyte/rxjs-observable-store} utility.
*/
@Injectable({
providedIn: 'root'
})
export class AppDataStore extends Store<AppData>{
constructor(private userService: UserService,
private productsService: ProductsService) {
super(new AppData());
//initialize the user
this.initUser();
}
initUser() {
//TODO: get authenticated user from localStorage or from login
const user: User = new User();
user.id = "admin-user";
this.userService.getUserRoles(user.id).pipe(first(),).subscribe(ur => {
user.appRoles = ur;
this.state.user = user;
});
}
loadProducts(): void {
if (this.state.products.length === 0) {
this.productsService.getProducts().pipe(first(),).subscribe(products => this.setState({
this.state,
products: products
}));
}
}
loadProduct(productId: number) {
//TODO: call the productService to get product details
const _product: Product = this.state.products.find(cp => cp.id === productId);
this.setState({
this.state,
product: _product
});
}
addProduct(product: Product): Product {
this.productsService.addProduct(product).pipe(first(),).subscribe(p => this.setState({
this.state,
products: [this.state.products, p]
}));
return product;
}
addToCart(productId: number) {
//add only if not already in the cart
if (!this.state.cartProducts.find(cp => cp.id === productId)) {
const product: Product = this.state.products.find(cp => cp.id === productId);
if (product) {
this.setState({
this.state,
cartProducts: [this.state.cartProducts, product]
});
}
}
}
}
The app-data.store.ts
uses AppData
as its model and acts as a single source of truth for the whole application. It can handle scenarios like which data may be pre-fetched for performance, which data may be stored in localStorage or sessionStorage, when to call the REST service, etc.
state-transitions.model.ts
import { Product } from '../product/product.model';
import { User } from './user.model';
export class AppData {
user: User = new User();
products: Product[] = [];
cartProducts: Product[] = [];
product: Product = new Product();
}
export class StateTransitionData {
user: User;
productId: number;
product: Product;
initState: string;
finalState: string;
preEvent: string;
postEvent: string;
}
Where the User
data model is user.model.ts
:
export class User {
id: string = "";
appRoles: string[] = [];
}
export enum AppRole {
ADMIN = "ADMIN",
CUSTOMER = "CUSTOMER"
}
3. Create Angular Components
For each of the pre-events, an Angular component is generated and updated to call the doTransition
function in the state-transitions.ts
file. The components generated are home.component.ts, products.component.ts, product-details.component.ts, add-to-cart.component.ts, add-product-form.component.ts, add-product.component.ts, cart.component.ts, and cart-button.component.ts. The source for products.component.ts
and products.component.html
are shown below. The source for the other components can be viewed in GitHub.
products.component.ts
x
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AppDataStore } from '../../state-transitions/app-data.store';
import { AppEvent } from '../../state-transitions/app-events.enum';
import { doTransition } from '../../state-transitions/state-transitions';
import { AppData, StateTransitionData } from '../../state-transitions/state-transitions.model';
import { AppViewState } from '../../state-transitions/view-states.enum';
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit {
appData$: Observable<AppData>;
viewState: string = AppViewState.ONLOADSUCCESSVIEW;
errorMessage: string;
stData: StateTransitionData;
constructor(private appDataStore: AppDataStore, private router: Router,
private activatedRoute: ActivatedRoute) { }
ngOnInit(): void {
this.stData = new StateTransitionData();
this.stData.preEvent = AppEvent.get_products;
this.stData = doTransition(this.appDataStore, this.stData);
if (this.stData.finalState !== AppViewState.PRODUCTSVIEW) {
this.router.navigate(['/**'])
}
this.appData$ = this.appDataStore.state$;
}
addProduct() {
this.router.navigate(['/add-product-form', {fromView: AppViewState.PRODUCTSVIEW}]);
}
}
products.component.html
x
<div class="error">{{errorMessage}}</div>
<h4>List of Products</h4>
<div *ngFor="let product of (appData$ | async).products;">
<a [routerLink]="['/product', product.id, {fromView: 'PRODUCTSVIEW'}]" routerLinkActive="active">{{product.name}}</a>
</div>
<p> </p>
<p>
<button *ngIf="stData.user.appRoles.includes('ADMIN')" (click)="addProduct()" routerLinkActive="active">Add Product</button>
</p>
4. Create Angular Routes for Each Pre-Event
This is a normal Angular routes configuration done in app-routing.module.ts
:
x
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { HomeComponent } from './home/home.component';
import { ProductsComponent } from './product/products/products.component';
import { ProductDetailsComponent } from './product/product-details/product-details.component';
import { CartComponent } from './cart/cart.component';
import { AddToCartComponent } from './product/add-to-cart/add-to-cart.component';
import { AddProductComponent } from './product/add-product/add-product.component';
import { AddProductFormComponent } from './product/add-product-form/add-product-form.component';
import { CartButtonComponent } from './cart/cart-button/cart-button.component';
const appRoutes: Routes = [
{
path: 'products',
component: ProductsComponent
},
{
path: 'product/:id',
component: ProductDetailsComponent
},
{
path: 'add-product',
component: AddProductComponent
},
{
path: 'add-product-form',
component: AddProductFormComponent
},
{
path: 'add-to-cart',
component: AddToCartComponent
},
{
path: 'cart-button',
component: CartButtonComponent
},
{
path: 'cart',
component: CartComponent
},
{ path: 'home', component: HomeComponent },
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];
@NgModule({
imports: [
RouterModule.forRoot(appRoutes)
],
exports: [
RouterModule
]
})
export class AppRoutingModule { }
Demo
A demo of the app developed using the pattern can be viewed here where all the transitions listed above can be tested.
Benefits
- The pattern provides a clear guideline when a new feature need to be added. For instance, if a new requirement like "if the user is not logged in then redirect to login page" need to be be added then the developer can proceed by writing additional state transitions like:
DEFAULT -> onload -> processOnload() -> onload_auth_error -> LOGINFORMVIEW
LOGINFORMVIEW -> login -> processLogin() -> login_success -> ONLOADSUCCESSVIEW
...and proceed with the remaining pattern steps.
- Guard conditions like isLoggedIn or isAuthorized, etc. are easily checked in one place - the
state-transitions.ts
file. - The use of one component per pre-event helps in keeping the codebase modular and easily maintainable.
Conclusion
The proposed Angular development pattern is shown to offer several benefits that could ease the development process.
Related Articles
Interested readers can check out the state transitions technique applied to Spring Boot projects in this previous DZone article.
Opinions expressed by DZone contributors are their own.
Comments