Local Component State Management
with @ngrx/component-store
Agenda
- What is Local Component State that we care about?
- What is Observable View Model?
- An overview of Angular DI
- What is @ngrx/component-store
- Condition Builder meets Component Store
- Q/A
Local Component State
Persisted State
URL State
Client State
UI State
Anything that contributes to rendering a Component
Persisted State
URL State
?query=&page=&
Client State
Local UI State
Local Component State
Persisted State
URL State
Client State
UI State
Anything that contributes to rendering a Component
Observable View Model
We know the states. Now we will look at HOW to deliver these states
Observable View Model
Traditional Pull-based Pattern
export class SomeComponent {
data: SomeData[];
constructor(private readonly someService: SomeService) {}
ngOnInit() {
this.someService.getData().subscribe(data => {
this.data = data;
})
}
addNewData(item) {
this.someService.addItem(item).subscribe(addedItem => {
this.data = [...this.data, addedItem]; // or this.data.push(addedItem)
});
}
refresh() {
this.someService.getData().subscribe(data => {
this.data = data;
})
}
}
<ul>
<li *ngFor="let item of data">
{{item}}
</li>
</ul>
<button (click)="addData('some new item')">
Add new item
</button>
<button (click)="refresh()">
Refresh
</button>
Observable View Model
Traditional Pull-based Pattern with a little bit more state
export class SomeComponent {
loading = false;
data: SomeData[] = [];
queryControl = new FormControl('')
constructor(private readonly someService: SomeService) {}
ngOnInit() {
this.loading = true;
this.someService.getData().subscribe(data => {
this.data = data;
this.loading = false;
})
this.queryControl.valueChanges.pipe(
debounceTime(250)
).subscribe(query => {
this.data = this.data.filter(...);
})
}
addNewData(item) {
this.loading = true;
this.someService.addItem(item).subscribe(addedItem => {
this.data = [...this.data, addedItem]; // or this.data.push(addedItem)
this.loading = false;
});
}
refresh() {
this.loading = true;
this.someService.getData().subscribe(data => {
this.data = data;
this.loading = false;
})
}
}
<ng-container *ngIf="loading;else done">
<spinner></spinner>
</ng-container>
<ng-template #done>
<input type="text" [formControl]="queryControl"/>
<ul>
<li *ngFor="let item of data">
{{item}}
</li>
</ul>
<button (click)="addData('some new item')">
Add new item
</button>
<button (click)="refresh()">
Refresh
</button>
</ng-template>
Observable View Model
Introducing Push-based Pattern
export class SomeStateService {
private readonly $data = new BehaviorSubject<SomeData[]>([]);
readonly data$ = this.$data.asObservable();
private readonly $loading = new BehaviorSubject<boolean>(false);
readonly loading$ = this.$loading.asObservable();
constructor(private readonly someService: SomeService) {}
getData() {
this.$loading.next(true);
this.someService.getData().subscribe({
next: data => {
this.$data.next(data);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
addData(item) {
this.$loading.next(true);
this.someService.addData(item).subscribe({
next: addedItem => {
this.$data.next([...this.$data.value, addedItem]);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
filter(query) {
this.$data.next(this.$data.value.filter(...));
}
}
Observable View Model
Introducing Push-based Pattern
@Component({
// ...
providers: [SomeStateService]
})
export class SomeComponent {
readonly data$ = this.someStateService.data$;
readonly loading$ = this.someStateService.loading$;
queryControl = new FormControl('')
constructor(private readonly someStateService: SomeStateService) {}
ngOnInit() {
this.someStateService.getData();
this.queryControl.valueChanges.pipe(
debounceTime(250)
).subscribe(query => {
this.someStateService.filter(query);
})
}
addData(item) {
this.someStateService.addData(item);
}
refresh() {
this.someStateService.getData();
}
}
<ng-container *ngIf="{loading: loading$ | async} as vm">
<ng-container *ngIf="vm.loading;else done">
<spinner></spinner>
</ng-container>
<ng-template #done>
<input type="text" [formControl]="queryControl" />
<ul>
<li *ngFor="let item of data$ | async">
{{item}}
</li>
</ul>
<button (click)="addData('some new item')">
Add new item
</button>
<button (click)="refresh()">
Refresh
</button>
</ng-template>
</ng-container>
Observable View Model
Introducing Push-based Pattern
export class SomeStateService {
private readonly $data = new BehaviorSubject<SomeData[]>([]);
readonly data$ = this.$data.asObservable();
private readonly $loading = new BehaviorSubject<boolean>(false);
readonly loading$ = this.$loading.asObservable();
constructor(private readonly someService: SomeService) {}
getData() {
this.$loading.next(true);
this.someService.getData().subscribe({
next: data => {
this.$data.next(data);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
addData(item) {
this.$loading.next(true);
this.someService.addData(item).subscribe({
next: addedItem => {
this.$data.next([...this.$data.value, addedItem]);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
filter(query) {
this.$data.next(this.$data.value.filter(...));
}
}
Observable View Model
Introducing Push-based Pattern
export class SomeStateService {
private readonly $data = new BehaviorSubject<SomeData[]>([]);
readonly data$ = this.$data.asObservable();
private readonly $loading = new BehaviorSubject<boolean>(false);
readonly loading$ = this.$loading.asObservable();
constructor(private readonly someService: SomeService) {}
getData() {
this.$loading.next(true);
this.someService.getData().subscribe({
next: data => {
this.$data.next(data);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
addData(item) {
this.$loading.next(true);
this.someService.addData(item).subscribe({
next: addedItem => {
this.$data.next([...this.$data.value, addedItem]);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
filter(query) {
this.$data.next(this.$data.value.filter(...));
}
}
ngrx/component-store
This is where ngrx/component-store comes in
ngrx/component-store
...but let's review this piece of codeĀ
@Component({
// ...
providers: [SomeStateService]
})
export class SomeComponent {
readonly data$ = this.someStateService.data$;
readonly loading$ = this.someStateService.loading$;
constructor(private readonly someStateService: SomeStateService) {}
ngOnInit() {
this.someStateService.getData();
}
addData(item) {
this.someStateService.addData(item);
}
refresh() {
this.someStateService.getData();
}
}
ngrx/component-store
Read
Write
Side-effect
select
updater
effect
@ngrx/store
createSelector
createReducer
createReducer
createEffect
ngrx/component-store
export class SomeStateService {
private readonly $data = new BehaviorSubject<SomeData[]>([]);
readonly data$ = this.$data.asObservable();
private readonly $loading = new BehaviorSubject<boolean>(false);
readonly loading$ = this.$loading.asObservable();
constructor(private readonly someService: SomeService) {}
getData() {
this.$loading.next(true);
this.someService.getData().subscribe({
next: data => {
this.$data.next(data);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
addData(item) {
this.$loading.next(true);
this.someService.addData(item).subscribe({
next: addedItem => {
this.$data.next([...this.$data.value, addedItem]);
this.$loading.next(false);
},
error: error => {
console.log(error);
this.$loading.next(false);
},
})
}
filter(query) {
this.$data.next(this.$data.value.filter(...));
}
}
export class SomeStore extends ComponentStore<SomeState> {
constructor(private readonly someService: SomeService) {
super({data: [], loading: false})
}
// Select
readonly vm$ = this.select(s => s);
// Updater
readonly setLoading = this.updater<boolean>((state, loading: boolean) => ({...state, loading}));
readonly setData = this.updater<SomeData[]>((state, data: SomeData[]) => ({...state, data}));
// Effect
readonly getDataEffect = this.effect($ => $.pipe(
tap(() => this.setLoading(true)),
switchMapTo(this.someService.getData().pipe(
tapResponse({
next: data => this.setData(data),
error: console.log
}),
finalize(() => {
this.setLoading(false);
})
))
))
readonly addDataEffect = this.effect<SomeData>(item$ => item$.pipe(
tap(() => this.setLoading(true)),
exhaustMap(item => this.someService.addData(item).pipe(
tapResponse({
next: newItem => this.setState(state => ({...state, data: [...state.data, newItem]})),
error: console.log
}),
finalize(() => {
this.setLoading(false)
})
))
))
readonly queryEffect = this.effect<string>(query$ => query$.pipe(
tap(query => {
this.setState(state => ({...state, data: state.data.filter(...)}));
})
))
}
interface SomeState {
loading: boolean;
data: SomeData[];
}
export class SomeStore extends ComponentStore<SomeState> {
constructor(private readonly someService: SomeService) {
super({data: [], loading: false})
}
// Select
readonly loading$ = this.select(s => s.loading);
readonly data$ = this.select(s => s.data);
readonly vm$ = this.select(
this.loading$,
this.data$,
(loading, data) => ({loading, data, isEmpty: !data.length})
);
// Updater
readonly setLoading = this.updater<boolean>((state, loading: boolean) => ({...state, loading}));
readonly setData = this.updater<SomeData[]>((state, data: SomeData[]) => ({...state, data}));
// Effect
readonly getDataEffect = this.effect($ => $.pipe(
tap(() => this.setLoading(true)),
switchMapTo(this.someService.getData().pipe(
tapResponse({
next: data => this.setData(data),
error: console.log
}),
finalize(() => {
this.setLoading(false);
})
))
))
readonly addDataEffect = this.effect<SomeData>(item$ => item$.pipe(
tap(() => this.setLoading(true)),
exhaustMap(item => this.someService.addData(item).pipe(
tapResponse({
next: newItem => this.setState(state => ({...state, data: [...state.data, newItem]})),
error: console.log
}),
finalize(() => {
this.setLoading(false)
})
))
))
}
interface SomeState {
loading: boolean;
data: SomeData[];
}
ngrx/component-store
@Component({
// ...
providers: [SomeStore]
})
export class SomeComponent {
readonly vm$ = this.someStore.vm$;
queryControl = new FormControl('');
constructor(private readonly someStore: SomeStore) {}
ngOnInit() {
this.someStore.getDataEffect();
this.someStore.queryEffect(
this.queryControl.valueChanges
.pipe(debounceTime(250))
);
}
addData(item) {
this.someStore.addDataEffect(item);
}
refresh() {
this.someStore.getDataEffect();
}
}
<ng-container *ngIf="vm$ | async as vm">
<ng-container *ngIf="vm.loading;else done">
<spinner></spinner>
</ng-container>
<ng-template #done>
<input type="text" [formControl]="queryControl" />
<ul>
<li *ngFor="let item of vm.data">
{{item}}
</li>
</ul>
<button (click)="addData('some new item')">
Add new item
</button>
<button (click)="refresh()">
Refresh
</button>
</ng-template>
</ng-container>
ngrx/component-store
// Effect in Store
readonly someEffect = this.effect<string>(...);
// Call someEffect and pass in a value of type string
this.store.someEffect(this.someValue);
// Call someEffect and pass in an Observable<string>
this.store.someEffect(this.someValue$);
// Eg: a FormControl to manage filter
queryControl = new FormControl('');
this.store.someEffect(this.queryControl.valueChanges.pipe(debounceTime(250)));
Condition builder
With all that knowledge, let's take a look at Condition Builder
Condition builder
Condition builder
ConditionBuilderStore
- Source of truth
- Use "Persisted Data" to populate the State (maxDepth and the tree)
- Contains Updaters and Effects to update the State
Condition builder
ConditionGroupStore
- Accompany ConditionGroupComponent
- Have ConditionBuilderStore injected
- Contains "Client State" and "UI State"
- Use ConditionBuliderStore's state to populate a Group
- Contains Updaters to update isCollapsedState
- Contains Effects that will call ConditionBuilderStore's effects
Condition builder
ConditionNodeStore
- Accompany ConditionNodeComponent
- Have ConditionBuilderStore injected
- Use ConditionBuliderStore's state to populate a ConditionNode
- Contains Effects that will call ConditionBuilderStore's effects
Condition builder
ConditionNodeStore
- Two main data stream that would update a node:
- ComparisonSelect changed
- ExpectedValue changed
Demo time
Component Store Guide
By Chau Tran
Component Store Guide
- 607