Angular State Management With BehaviorSubject
ng-conf
Posted on May 31, 2021
Jim Armstrong | ng-conf | Dec 2019
This article is targeted to beginning-to-intermediate-level Angular developers wishing to obtain insight into methods for state management in front-end applications. A simple, but highly customizable, reactive state-management service is constructed on top of the RxJs BehaviorSubject. This approach can be used for both management of a global store or as model manager for a lazy-loaded route. Both use cases are illustrated through an Angular version 8 application.
While the code in this application may be extended for practical use in your own applications, there is another motivation for studying the internals of state management. Doing so provides a better understanding of the underlying details of such management, which makes you a better consumer of third-party state-management software.
So, let’s get started!
Introduction
The benefits of a reactive store include the ability to manage mutability risk and facilitate communication of actions to any components having visibility to the store. While third-party packages such as @ngrx/store provide complete packaged solutions to state management, sometimes a third-party package is just too heavyweight, or it might be considered overkill for a lazy-loaded route. For example, some routes require state, but only components involved in the route require exposure to that state. The remainder of the application has no need of information in that route’s store, so why use the global store? In other words, what happens inside the route stays inside the route. This has been my most frequent use case for lightweight, custom state management.
Before continuing, some prior exposure to state-management concepts is useful in understanding this article and the supplied code. This article on general concepts behind Redux may be helpful for those needing a refresher.
A minimal understanding of RxJs is also required. You may also find this very helpful,
https://github.com/DanWahlin/Observable-Store
Preliminaries
In the interest of keeping custom state management lightweight and performant, the model for this application is ‘open’. By ‘open’, it is meant that a specific model for a specific application is defined inside an Angular service. Only that service has direct access to the model and only the service can update the model. A copy of the current model or state may be obtained by subscribers to model updates.
Many models are simple JS objects (name/value pairs) and payloads are often empty. The service may employ pure functions in the process of validating payloads supplied with various actions and transforming the model, but there are no formal reducers in this approach.
Note that this approach is not general-purpose; changing the application requires modifying the model service. There is also nothing new presented, although I believe the current illustration of the technique to be more involved than most introductory treatments on the topic. And, it’s simple enough so that even a mathematician like me can make sense of it :)
The Application
Before we begin deconstruction, here is the GitHub for the Angular application,
theAlgorithmist/AngularStateManagement on github.com
The most common applications for illustrating state-management systems are counters and to-do lists. This application serves as an interesting (and much more practical) twist on the classic counter. The application contains three simple menu options, which represent primary paths through the application. They are named Path 1, Path 2, and Path 3.
Organization of the application is illustrated, below.
Application Organization
Path 1 and Path 3 are eagerly loaded. The application keeps track of how many times each route has been loaded and displays count-dependent content inside each route. Current counts are displayed adjacent to the links to each path.
Path 2 is lazy-loaded and contains two child components, Path 2A and Path 2B. Path 2 maintains counts of how often its A/B routes are loaded, but that information is only of interest inside the main Path 2 route. The remainder of the application is unconcerned about any count information inside the Path 2 route.
Two models are used inside this application. The first represents the global store or application state, which consists of the user’s first and last name, user role, path-1 count, and path-3 count. A record of the latest application action is also maintained in the model as shown in /app/shared/IAppModel.ts,
export interface IAppMode
_{
action: string;
first: string;
last: string;
role: string;
path1Count: number,
path3Count: number,
};
export const INIT_APP_MODEL: IAppModel = {
action: appActions.NONE,
first: 'First',
last: 'Last',
role: 'None',
path1Count: 0,
path3Count: 0
};
An interface is defined that describes the shape of the global store along with an initial model. That model is managed in an Angular service, /app/shared/IAppModel.ts.
This service exposes several methods that allow the model to be reactively updated in a Redux-style manner. This is accomplished using the RxJs BehaviorSubject. BehaviorSubject is a Subject (so it acts as both Observer and Observable) that accepts an initial value. It is defined with a specified type,
protected subject: BehaviorSubject<IAppModel>;
for purposes of the application covered in this article.
A protected model reference is defined as
protected model: IAppModel;
which serves as the concrete representation of the model’s state at any time in the application. This representation can only be mutated inside the model service.
The model is initialized in the constructor.
constructor()
{
this.model = JSON.parse(JSON.stringify(INIT_APP_MODEL));
this.subject = new BehaviorSubject<IAppModel>(this.model);
}
Any component interested in subscribing to model updates does so through the public subscribe method,
public subscribe(callback: (model: IAppModel) => void): Subscription {
return this.subject.subscribe(callback);
}
The model is updated by dispatching named ‘actions,’ which are handled in the public dispatchAction method. This method defines a string action and optional payload as arguments.
As an exercise, try exposing the internal subject variable as a public Observable. This allows developers to capture error information in a familiar manner. The Observable could be initialized in the constructor, this.currentModel$ = this.subject.asObservable(), for example. This Observable could be used as an alternative to a Redux-style subscription.
In Redux terms, the actual model update would typically be handled by independent reducer functions. For compactness, state updates are handled internally in the dispatchAction method. Pure helper functions could also be used for more complex updates.
public dispatchAction(act: string, payload: any | null): void {
this.model.action = act;
switch (act)
{
case appActions.NONE:
// placeholder for future use
console.log('no action');
break;
case appActions.INC_PATH1:
this.model.path1Count++;
break;
case appActions.INC_PATH3:
this.model.path3Count++;
break;
case appActions.CLEAR:
this.model.path1Count = 0;
this.model.path3Count = 0;
break;
case appActions.USER:
// todo add data validation as an exercise
const data: Partial<IAppModel> = payload as Partial<IAppModel>;
this.model.first = data.first;
this.model.last = data.last;
this.model.role = data.role;
}
const dispatchedModel: IAppModel = JSON.parse(JSON.stringify(this.model));
this.subject.next(dispatchedModel);
}
Notice at the end of the method that a copy is made of the updated model and that copy is broadcast to all subscribers. Also note that the SAME copy is sent to all subscribers, so it is theoretically possible that any one component could mutate their copy and spread that mutation to other subscribers. To alleviate this situation, use Object.freeze() on the model copy.
At this point, the application has a global state or model and that model can be updated by any component simply by dispatching an appropriate action with accompanying payload. Any component can subscribe (and react to) model updates.
Using The Model In The Application
The main app component’s template illustrates the overall layout and function of the route-counter application,
/src/app/app.component.html
<header>Angular 8 Custom State Management</header>
<div class="padded">User: {{appModel.first}} {{appModel.last}} Role: {{appModel.role}}</div>
<div class="nav">
<span class="nav-option mr10">
<a routerLink="path1">Path 1</a> ({{appModel.path1Count}})
</span>
<span class="nav-option mr10">
<a routerLink="path2" [state]="{first: appModel.first}">Path 2</a>
</span>
<span class="nav-option">
<a routerLink="path3">Path 3</a> ({{appModel.path3Count}})
</span>
</div>
<router-outlet></router-outlet>
Some aspects of this template require further deconstruction and that is deferred until a later point in this article.
The main application (/src/app/app.component.ts) obtains copies of the app model by injecting the model service and subscribing to updates,
public appModel: IAppModel;
protected _storeSubscription: Subscription;
constructor(protected _modelService: ModelService,
protected _http: HttpClient)
{
this._storeSubscription = this._modelService.subscribe( (m: IAppModel) => this.__onModelUpdated(m));
}
The appModel variable is used for binding. Several model variables are reflected in the template and each application path is loaded into the supplied router outlet (see template above).
Routes are defined in the main app routing module (/src/app/app-routing.module.ts)
const routes: Routes = [
{
path : '',
redirectTo: '/path1',
pathMatch : 'full',
},
{
path : 'path1',
component: Path1Component
},
{
path : 'path3',
component: Path3Component
},
{
path : 'path2',
loadChildren: () => import('./features/path2/path2.module').then(m => m.Path2Module),
}
];
Note that path1 and path3 are eagerly loaded (and associated with Angular Components Path1Component and Path3Component). The path2 route is lazy-loaded and its full description is deferred to Path2Module.
The path-1 and path-3 components use the number of times the component was loaded to display some sort of ‘recognition’ to the user. This is a frequent application in EdTech where ‘badges’ and other rewards are displayed based on scores, counts, and other achievement criteria.
Only Path1Component is deconstructed in this article (Path3Component is nearly identical),
/src/app/features/path1/path1-component.ts
This component’s template is inlined to conserve space,
@Component({
selector: 'app-path1',
template: `<p>(Eager) Path 1 Component</p>
<p *ngIf="showBadge">Congratulations!!</p>
<p>This is some text associated with Path 1, blah, blah ...</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
As with the main app component, the app model service is injected and the component subscribes to updates,
constructor(protected _modelService: ModelService)
{
this._storeSubscription = this._modelService.subscribe( (m: IAppModel) => this.__onModelUpdated(m));
}
Since components are moved in and out of the main application’s router outlet, the on-init lifecycle handler is used to increment the path-1 count,
public ngOnInit(): void
{
// For better unit testing, move this logic outside the lifecycle handler.
this._modelService.dispatchAction(appActions.INC_PATH1, null);
}
Anywhere a subscription is made, it’s good practice to unsubscribe when the component is destroyed,
public ngOnDestroy(): void
{
this._storeSubscription.unsubscribe();
}
Model updates are handled below, which shows how the local showBadge variable is assigned.
protected __onModelUpdated(model: IAppModel): void
{
if (model !== undefined && model != null) {
this.showBadge = model.path1Count > 4;
}
}
Now, the local showBadge variable is not an Input and it is updated inside a Component with OnPush change detection. This works in the current application since the only way the model can be updated is by clicking a link. In general, a ChangeDetectorRef should be injected and then add a call to markForCheck(). Consider this modification as an exercise and note that it applies to other components as well.
Note that the path-1 increment occurs when the main app model is updated as a result of the dispatch that occurs in the on-init handler. This also allows any other subscriber to react to the path-1 count update without any alteration to the application’s structure or architecture.
In practice, a more sophisticated badge formula would be used, which could likely be encapsulated in a standalone, pure function. The latter is better for testing purposes. Currently, the only way to test this component is to directly modify an app model (although its compactness is easier to deconstruct). Try altering this approach yourself as a means of gaining better familiarity with the code base.
Lazy-Loaded Route Model
The path-2 (lazy-loaded) route is different in that it has a main component associated with the route as well as other components whose load-counts are required while in path-2. The remainder of the application is unconcerned with this information, so a separate model is employed with the path-2 route,
/src/app/features/path2/shared/IPath2Model.ts
export interface IPath2Model
{
action: string;
first: string;
last?: string;
selection: string;
path2CountA: number,
path2CountB: number,
};
export const INIT_PATH2_MODEL: IPath2Model = {
action: path2Actions.NONE,
first: '',
selection: '',
path2CountA: 0,
path2CountB: 0,
};
The complete path-2 model is provided in /src/app/features/path2/shared/path2-model.service.ts
Since this model is only required for the path-2 route, it is not necessary to register it with the root injector. It is simplest and easiest (to avoid working around apparent circular dependencies with ‘providedIn’) to provide this service in the path-2 module (/src/app/features/path2/path2.module.ts)
@NgModule({
declarations: [],
imports: [
CommonModule,
Path2RoutingModule,
],
providers: [Path2ModelService]
})
Route-To-Route Data Transfer
Only the user’s first name is required in path-2 and that information is contained in the main app model. So, how do we transfer the first name from the main app model to the path-2 model? This could be accomplished in a few ways, one of which is to inject both models into Path2Component and then simply use the first name from the main app model. This requires the ability to select a copy of the current model, which is not currently provided in the abbreviated code base for this article.
Adding a select() method to the model is easy, however, if you already added the public Observable as suggested above, such a method is not necessary.
The current approach uses dynamic state to pass the first name whenever the user clicks on the path-2 link, as shown in
/src/app/app.component.html
<header>Angular 8 Custom State Management</header>
<div class="padded">User: {{appModel.first}} {{appModel.last}} Role: {{appModel.role}}</div>
<div class="nav">
<span class="nav-option mr10">
<a routerLink="path1">Path 1</a> ({{appModel.path1Count}})
</span>
<span class="nav-option mr10">
<a routerLink="path2" [state]="{first: appModel.first}">Path 2</a>
</span>
<span class="nav-option">
<a routerLink="path3">Path 3</a> ({{appModel.path3Count}})
</span>
</div>
<router-outlet></router-outlet>
This provides what Angular calls Navigation Extras that can be picked up via the router’s getCurrentNavigation() method as will be illustrated later. This is a bit more compact and allows me to point out a pitfall of this technique that is rarely discussed in other tutorials on the topic.
Path2Component Deconstruction
As with the other components, this component’s template is inlined,
/src/app/features/path2/components/path2-component.ts
@Component({
selector: 'app-path2',
template: `<p>(Lazy) Path 2 Component</p>
<p> <a [routerLink]="'/path2/a'">Path 2a</a> ({{path2Model.path2CountA}})
<a [routerLink]="'/path2/b'">Path 2b</a> ({{path2Model.path2CountB}})</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
Notice that child components path-2 A and B are routed in place of path 2. But, there is no additional router outlet in Path2Component. This means that all components are loaded into the main app’s router outlet and all routes need to be defined relative to the main app. I suggest adding a router outlet to Path2Component and modifying the route definitions as an exercise. This forces you to work with and become familiar with the code as opposed to simply copy/paste and use it directly in applications.
It also means that for purposes of the current application, it’s necessary to navigate back to the path-2 route before moving onto path 2 A or B. Note that the path-2 route definitions are in /src/app/features/path2/path2-routing.module.ts.
The component maintains a public reference to a path-2 model, that is used for binding,
public path2Model: IPath2Model;
The component’s constructor subscribes to model updates and retrieves the first-name variable passed as a navigation extra,
constructor(
protected _router: Router,
protected _modelService: Path2ModelService
)
{
const state: NavigationExtras = this._router.getCurrentNavigation().extras.state;
if (state !== undefined) {
this._modelService.dispatchAction(path2Actions.INIT, {first: state['first']});
}
this._storeSubscription = this._modelService.subscribe( (m: IPath2Model) => this.__onModelUpdated(m));
}
This seems like a handy trick to pass dynamic data between routes, but there is a caveat. If the route is directly loaded into the browser’s URL bar, the main application’s routerLink is never activated (since the link is never clicked) and the state is never passed. So, the state will be undefined in Path2Component. In an actual application, one would likely use route guards to make sure all users go through ‘the front door,’ but I wanted to illustrate this issue and point out that there are better ways to do this using the existing model.
The primary action taken on path-2 model updates is to update the reference to the public path-2 model and then let Angular do its work :) Once again, take note of the above comments on change detection.
Testing The Application
The application simulates the process of loading some initial data from a server and then using this data to populate the global store (or state).
/src/app/app.component.html
public ngOnInit(): void
{
this._http.get<IAppModel>('/assets/client-data.json')
.subscribe( (data: IAppModel) => this.__onDataLoaded(data) );
}
.
.
.
protected __onDataLoaded(data: IAppModel): void
{
this._modelService.dispatchAction(appActions.USER, data);
}
The USER action causes data to be copied into the state and then subscribers receive the new model in an update. This results in all subscribers receiving the initial model hydration (INIT_APP_MODEL) as well as the update from external data. The UI is then redrawn with the new user information as well as the default (eager) route counts.
Click back and forth between the path-1 and path-3 links and watch the route counts update. After loading path-1 the minimal number of times, you should see the path-1 recognition appear.
Load path-2 and move back and forth between path-2 and its A/B paths. Note that path-2 information is only maintained inside path-2 and persists between loads of the path-2 route.
Summary
This article illustrated the creation of a very simple, Redux-style model using BehaviorSubject. The approach is simple and very lightweight, but needs to be customized to each individual application. With only slight modification, I’ve used a similar approach to managing local state inside complex, lazy-loaded routes in actual applications. Take the time to study the code, make the suggested modifications, and then you may well discover future applications of these techniques in your own projects.
Good luck with your Angular efforts!
ng-conf: Join us for the Reliable Web Summit
Come learn from community members and leaders the best ways to build reliable web applications, write quality code, choose scalable architectures, and create effective automated tests. Powered by ng-conf, join us for the Reliable Web Summit this August 26th & 27th, 2021.
https://reliablewebsummit.com/
Posted on May 31, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 20, 2024
November 15, 2024