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

Made with Slides.com