Custom Angular Application State Service With BehaviourSubject
The biggest buzzwords in the Angular scene right now are actually concepts made mainstream by React: Application state or store using a Redux pattern. NgRx is a fantastic library which allows Angular developers to use a Redux store to maintain application state. It's really powerful, and integrating it with @ngrx/effects
or metaReducers
as well as @ngrx/router-store
makes it the go-to solution for complex application architectures.
However, NgRx introduces a lot of boilerplate, complexity and has a very steep learning curve. In addition, the recent rise of NgXS suggests that the NgRx pattern though popular, may not be the Angular way of managing application state.
In this tutorial, we will look at implementing our own application state service inspired by NgRx. It will not follow any pre-defined patterns, but offer us a very similar experience to NgRx. The idea behind implementing an application state behind a service is to reduce boilerplate, reduce complexity for smaller development teams and applications as well as offering an alternative and fully-customisable approach to application state whilst keeping the best bits of NgRx.
Building a custom Angular application state service with BehaviourSubject
For this example, we will be using a MyApplicationStateService
to store a simple object which represents a multi-page user registration form. The object is the payload that is sent to the server upon submission of the form, and each property of the object represents one page of this multi-page registration form.
Step 1: Start by creating a blank service. Be wary about which module the service belongs to, especially when you are lazy-loading sub-modules, as services not in your root module will not be singletons and will not instantiate at the same time and contain the same data.
If you are using the Angular CLI, the ng generate service my-application-state
command will scaffold an empty service for you.
import { Injectable } from '@angular/core';
@Injectable()
export class MyApplicationStateService {
constructor() { }
}
Step 2: For this example, you will need to add the following import
s to your service:
import { Observable, BehaviorSubject } from 'rxjs';
You may need to import the RxJS
library with the command npm install rxjs --save
.
Step 3: Add a class variable to the service in which you want to store your data. More complex applications can have multiple class variables to store data, but I recommend one service for each 'store'. For the purpose of simplicity, the variable used in this example will be of type BehaviourSubject<any>
, but you can just as easily declare it of any custom-defined type using the standard TypeScript syntax. It must be of type BehaviourSubject
so that our application can subscribe to the value, and so that our service can emit any changes to subscriptions across the app.
You must initialise the value of your variable in the constructor()
. You may define an initial state and assign this value. Defining an initial state is particularly useful if your object contains nested objects and arrays. To keep things simple, the initial state in this example is defined in-line within the constructor()
.
The my-application-state.service.ts
file now looks like this:
import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
@Injectable()
export class MyApplicationStateService {
private _form: BehaviorSubject;
constructor() {
this._form = new BehaviorSubject({
name: null,
address: {
line1: null,
city: null,
postalCode: null,
country: null
},
contactNumbers: []
});
}
}
Step 4: Now add three getter/setter methods to the service:
get form$(): Observable {
return this._form.asObservable();
}
The above getter allows you to subscribe to the _form
property as an Observable
. This will be familiar to anyone who has used Angular's HttpClientModule
and NgRx
. For example, in a component the code
this.myApplicationState.form$.subscribe(state => { this.componentVariable = state });
will always update the component variable componentVariable
to the value stored in the service when it changes. If you do not want to access the state as an Observable
you do not need to include this getter method.
get form(): any {
return this._form.value;
}
The get application()
method returns the value state stored in the service at the moment it is called. This is suitable for scenarios where you just want to retrieve the value, or when subscribing is not appropriate. This is the standard way of storing state in a singleton service and does not harness the power of RxJS
.
set form(nextState: any) {
this._form.next(nextState);
}
The method above method allows components to update the value of the state stored within the service. There are many different approaches to this, and personally I prefer creating a dispatch()
method which gives more control over the arguments passed to update the state.
Step 5: At this stage you can now use the application state service in your application. To retrieve the value of the state as a value use this.myApplicationState.form
, to retrieve the value as an Observable
use this.myApplicationState.form$
and to set the value of the state use this.myApplicationState.form = {}
.
Keep in mind that the value assigned using the setter (this.myApplicationState.form = {}
) will overwrite anything already stored in the state.
Exploring the option of a dispatch() method
Rather than using the setter
method, I prefer using a watered-down dispatch()
method which can the be extrapolated to be more powerful on a case-by-case basis. Here is an example of a dispatch()
method for the above MyApplicationState
service:
dispatch(action: string, payload: any = null) {
switch (action.toLowerCase()) {
case 'updatename':
this._updateName(payload);
break;
case 'updateaddress':
this._updateAddress(payload);
break;
case 'addcontactnumber':
this._addContactNumber(payload);
break;
case 'removecontactnumber':
this.removeContactNumber(payload);
break;
default:
console.error(`dispatch action ${action} not recognised`);
break;
}
}
The action string allows user to easily define what we want to do with the store, and allows us to implement functionality within the service to handle the change. The _updateName()
method may take a string payload and overwrite the existing value. The _removeContactNumber()
method may take an integer payload which represents the array index to be removed. The _addContactNumber()
method takes a string payload which is added to an array. An example of this implementation is below:
private _addContactNumber(contactNumber: string): void {
let form = this._form.value;();
form.contactNumbers.push(contactNumber);
this._form.next(form);
}
This method copies the existing value of the store (keeping it immutable), adds the contact number to the array and then emits the update using the next()
method on the BehaviorSubject
class. Any subscriptions to the form Observable
will automatically be updated with the new value.
When taking this approach, it is important to pay close attention to the protection of class variables and methods - especially if you are writing a service API that multiple developers may access. For example, the _addContactNumber()
method must be private to avoid developers accidently calling this.myApplicationState._addContactNumber()
directly from a component.