State management is already a well-established concept in programming but that does not mean it’s a done deal. Widely used in commercial applications, the concept is still evolving and new ideas are still being developed.
In this article, I’m going to focus on web applications built with Angular, however some concepts may be similar and applicable to other frontend frameworks for software development.
Angular state management evolution
Let’s begin with a quick reminder about the evolution of state handling in web apps. Around 2015, when the Redux concept was introduced, the only state was the global state app. That meant every part of an application that had a state, like elements on the list that the user is scrolling, scroll position, currently highlighted element, etc. should have its own representation in the global state object. Besides the many advantages of this approach there was one major problem. In most cases the state went big, even in small or medium-sized applications and all the action, sectors and reducer layer as well.
As developers we like to have small layers to maintain, so to improve the situation something called the feature state was introduced. The feature state is a similar concept to feature-driven architecture but applies to state. In other words, now the global state consists of as many feature states as needed, so we keep all the advantages of the Redux pattern in the application, but have smaller pieces of state to maintain. Sounds great, so where to go from here?
One of the trends is to optimize the libraries to require less boilerplate code, be faster, safer, provide a better developer experience, etc. This is happening constantly. Another trend is to have the state consist of even smaller pieces and this is our focus here.
In some applications we may have standalone components or other pieces that are independent, we may not want them to be connected to the global state, or we are refactoring an application to use state management. In such cases, we may use a concept called local or component state.
Check out our frontend development services if you're looking for support in frontend-related issues.
Demo application - fundamentals
For demo purposes, I created a simple audio equalizer app with four controls. The application has two pages where the logic and store interaction is placed and one shared presentation component that is connected in one case to the ngrx/component store and in another to rx-angular/state.
For the record, below I have updated my package.json screen to show on which versions I did the demo. The most important are:
- Angular version 14.2.0
- NgRx/component-store version 14.2.0
- RxAngular/state version 1.7.0
"dependencies": {
"@angular/animations": "^14.2.0",
"@angular/cdk": "^14.2.2",
"@angular/common": "^14.2.0",
"@angular/compiler": "^14.2.0",
"@angular/core": "^14.2.0",
"@angular/forms": "^14.2.0",
"@angular/material": "^14.2.2",
"@angular/platform-browser": "^14.2.0",
"@angular/platform-browser-dynamic": "^14.2.0",
"@angular/router": "^14.2.0",
"@ngrx/component-store": "^14.2.0",
"@rx-angular/state": "^1.7.0",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^14.2.9",
"@angular/cli": "~14.2.9",
"@angular/compiler-cli": "^14.2.0",
"@types/jasmine": "~4.0.0",
"jasmine-core": "~4.3.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"typescript": "~4.7.2"
}
Next I would like you to have a look at the EqualizerComponent. It basically has four inputs, four outputs that emit a new value whenever any of the control values are changed, and uses Angular Material Slider and Slide Toggle for a better user experience. I also added an OnChanges lifecycle hook with a console log to ensure change detection is not triggered redundantly.
<div class="app-equalizer">
<div>
<span>Bass: </span>
<mat-slider min="0" max="100" step="1" [value]="bass" (valueChange)="setBass($event)">
<input matSliderThumb>
</mat-slider>
<span> {{ bass }} </span>
</div>
<div>
<span>Middle: </span>
<mat-slider min="0" max="100" step="1" [value]="middle" (valueChange)="setMiddle($event)">
<input matSliderThumb>
</mat-slider>
<span> {{ middle }} </span>
</div>
<div>
<span>Treble: </span>
<mat-slider min="0" max="100" step="1" [value]="treble" (valueChange)="setTreble($event)">
<input matSliderThumb>
</mat-slider>
<span> {{ treble }} </span>
</div>
<div>
<mat-slide-toggle [checked]="boost" (change)="setBoost($event)">Boost</mat-slide-toggle>
</div>
</div>
@Component({
selector: 'app-equalizer',
templateUrl: './equalizer.component.html',
styleUrls: ['./equalizer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EqualizerComponent implements OnChanges {
@Input() bass: number | null = 0;
@Input() middle: number | null = 0;
@Input() treble: number | null = 0;
@Input() boost: boolean | null = false;
@Output() bassChange = new EventEmitter<number>();
@Output() middleChange = new EventEmitter<number>();
@Output() trebleChange = new EventEmitter<number>();
@Output() boostChange = new EventEmitter<boolean>();
setBass = (value: number | null) => this.bassChange.emit(value ? value : 0);
setMiddle = (value: number | null) => this.middleChange.emit(value ? value : 0);
setTreble = (value: number | null) => this.trebleChange.emit(value ? value : 0);
setBoost = (value: MatSlideToggleChange) => this.boostChange.emit(value.checked);
ngOnChanges(changes: SimpleChanges) {
console.log('EqualizerComponent -> ngOnChanges: ', changes)
}
}
Template of demo application
The important parts of both the RxAngularStorePageComponent and NgrxComponentStorePageComponent templates looks exactly the same - it passes observables with the help of the async pipe to EqualizerComponent and handles events raised by EqualizerComponent. There is also a full state log done with JSON and async pipe for debugging purposes.
<app-equalizer
[bass]="bass$ | async"
[middle]="middle$ | async"
[treble]="treble$ | async"
[boost]="boost$ | async"
(bassChange)="onBassChange($event)"
(middleChange)="onMiddleChange($event)"
(trebleChange)="onTrebleChange($event)"
(boostChange)="onBoostChange($event)"
></app-equalizer>
<div>
<pre>
{{(state$ | async) | json }}
</pre>
</div>
>> Find out the newest frontend trends
Component state management in demo application
Finally, it’s time to look at the libraries. Both rx-angular/store and ngrx/component-store may be implemented into components in two ways. One is to extend the class that we want to have the state in with RxState or ComponentStore. This approach will probably be the one you need when working with a framework other than Angular. You can also use it if you want to build a facade service that has state management built in.
The approach that I’m going to use is to provide RxState and ComponentStore in my designated component providers array and inject it in the component constructor.
A quick reminder about dependency injection hierarchy: now all the components from NgrxComponentStorePageComponent have access to the same instance of ComponentStore as the NgrxComponentStorePageComponent itself, unless a separate instance of ComponentStore is provided for them. The same goes for RxAngularStorePageComponent and RxState. This may be a wonderful feature to shorten the data path avoiding unnecessary inputs and outputs, but beware of misusing this feature, because you may create lots of non-reusable components.
Before we look into NgrxComponentStorePageComponent and RxAngularStorePageComponent I would like to present one last thing that is shared between those two components, the state interface and initial state variable. In a case when RxState and ComponentStore initial state implementation is optional, what is the difference from most state management libraries where an initial state is mandatory? When we do not set anything as state then the state is just null.
export interface EqualizerState {
bass: number,
middle: number,
treble: number,
boost: boolean
}
import {EqualizerState} from "./equalizer-state";
export const EqualizerInitState: EqualizerState = {
bass: 0,
middle: 0,
treble: 0,
boost: false
}
Now, the final part: NgrxComponentStorePageComponent and RxAngularStorePageComponent using rx-angular/store and ngrx/component-store state management. You should notice that those two files look very alike.
To summarize what is happening in both files, we provide the store provider in the component providers, inject it in the constructor like any other service injection, set the initial state in the component lifecycle hook OnInit, get some state properties as observables, and last but not least, modify the state when an event happens. You may notice a slight difference in the state service API methods, but that’s all.
import ...
@Component({
selector: 'app-ngrx-component-store-page',
templateUrl: './ngrx-component-store-page.component.html',
styleUrls: ['./ngrx-component-store-page.component.scss'],
providers: [ComponentStore]
})
export class NgrxComponentStorePageComponent implements OnInit {
readonly state$ = this.componentStore.state$;
readonly bass$ = this.componentStore.state$.pipe(map(state => state.bass));
readonly middle$ = this.componentStore.state$.pipe(map(state => state.middle));
readonly treble$ = this.componentStore.state$.pipe(map(state => state.treble));
readonly boost$ = this.componentStore.state$.pipe(map(state => state.boost));
constructor(private readonly componentStore: ComponentStore<EqualizerState>) { }
ngOnInit(): void {
this.componentStore.setState(EqualizerInitState)
}
onBassChange = (bass: number) => this.componentStore.patchState({bass});
onMiddleChange = (middle: number) => this.componentStore.patchState({middle});
onTrebleChange = (treble: number) => this.componentStore.patchState({treble});
onBoostChange = (boost: boolean) => this.componentStore.patchState({boost});
}
import ...
@Component({
selector: 'app-ngrx-component-store-page',
templateUrl: './ngrx-component-store-page.component.html',
styleUrls: ['./ngrx-component-store-page.component.scss'],
providers: [ComponentStore]
})
export class NgrxComponentStorePageComponent implements OnInit {
readonly state$ = this.componentStore.state$;
readonly bass$ = this.componentStore.state$.pipe(map(state => state.bass));
readonly middle$ = this.componentStore.state$.pipe(map(state => state.middle));
readonly treble$ = this.componentStore.state$.pipe(map(state => state.treble));
readonly boost$ = this.componentStore.state$.pipe(map(state => state.boost));
constructor(private readonly componentStore: ComponentStore<EqualizerState>) { }
ngOnInit(): void {
this.componentStore.setState(EqualizerInitState)
}
onBassChange = (bass: number) => this.componentStore.patchState({bass});
onMiddleChange = (middle: number) => this.componentStore.patchState({middle});
onTrebleChange = (treble: number) => this.componentStore.patchState({treble});
onBoostChange = (boost: boolean) => this.componentStore.patchState({boost});
}
Summary
Component state management may be useful in many cases, for example:
- when you need a separate, independent part of the state in some part of the app,
- or when you’re refactoring components,
- or when you want to introduce state management at low cost to your app.
Both rx-angular/store and ngrx/component-store are great ways to achieve component state management, the question is which one to choose. I would say that if you already have in your app some part of NgRx or RxAngular then be consistent. For example, if you’re already using rx-angular/cdk it makes sense to go with RxAngular/store rather than NgRx.
On the other hand, it should be mentioned that NgRx has been on the market longer, has a bigger team and has really good documentation with lots of examples and use cases described. So, as to the question of the developers’ favorite: it depends. It’s also worth mentioning that for developers tackling component state management for the first time, NgRx is a great and easy introduction to the concept.
Finally, let’s mention a disadvantage of component state. It has much less unit testing potential. If instead we go with global or feature state when we write our selectors, actions, reducers, all those items are pure functions which are really good unit testing material. The case is a bit more complicated with side effects, but still those are unit testable too. Component store is also not connected to Redux Devtools, which I personally find one of the strongest arguments to use state management. Nothing comes without a price and the responsibility to choose the best toolset is still on developers shoulders.