Angular アプリで NgRx を NGXS に置き換えてみた

# 背景

Angular アプリを NgRx を使って作ったが、 NgRx よりも NGXS の方がシンプルに書けるようなので、置き換えてみることにした。
会社でも NGXS を使ってるが、まだ使い方など腑に落ちてないので、NGXS の学習も含めて取り組んでみる。

# 参考記事

以下の記事を参考に、置き換えに取り組む。

https://qiita.com/aqlwah/items/c947ed3aa74625854ea9

# やってみる

## NGXS のインストール

```bash:bash
npm install @ngxs/store --save
```

ついでに、 Angular のバージョンを 12 -> 13 にバージョンアップする。

```bash:bash
npx @angular/cli@13 update @angular/core@13 @angular/cli@13
npx @angular/cli@13 update @angular/material@13
```

https://update.angular.io/?v=12.0-13.0

`ng serve` でエラーが出たので、 compilerOptions に `skipLibCheck: true` を入れておく。

https://stackoverflow.com/questions/61735762/error-ts2344-type-tk-does-not-satisfy-the-constraint

## Action を定義する

`~~~.actions.ts` に定義していた Action とほぼ同じ。
Reducer が不要となるため、

```typescript
export type TrainingActions = SetAvailableTrainings | SetFinishedTrainings | StartTraining | StopTraining;
```

のようなコードは削除しておく。

また、import を簡単にするため、 `namespace` を導入する。

```typescript:src/app/training/training.actions.ts
import { Exercise } from './exercise.model';

export const SET_AVAILABLE_TRAININGS = '[Training] Set Available Trainings';
export const SET_FINISHED_TRAININGS  = '[Training] Set Finished Trainings';
export const START_TRAINING          = '[Training] Start Training';
export const STOP_TRAINING           = '[Training] Stop Training';

export namespace TrainingAction {
  export class SetAvailable {
    static readonly type = SET_AVAILABLE_TRAININGS;

    constructor(public payload: Exercise[]) {}
  }

  export class SetFinished {
    static readonly type = SET_FINISHED_TRAININGS;

    constructor(public payload: Exercise[]) {}
  }

  export class Start {
    static readonly type = START_TRAINING;

    constructor(public payload: string) {}
  }

  export class Stop {
    static readonly type = STOP_TRAINING;
  }
}
```

## State を定義する

状態を表す State を定義する。
State クラスを記述するため、 `training.state.ts` を作成する。

## Store モジュールを import する

ルートモジュールに Store モジュールを import する。
Store で管理する State を配列として渡し、 `forRoot()` で初期化する。

```typescript:src/app/app.module.ts
...
import { NgxsModule }              from '@ngxs/store';

...
import { TrainingState }        from './training/training.state';

@NgModule({
  declarations: [
    ...
  ],
  imports: [
    ...
    NgxsModule.forRoot([TrainingState])
  ],
  providers: [
    ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
```

## コンポーネントから Action をディスパッチする

training.service.ts のロジックを修正する。
`@ngxs/store` を inport し、各メソッドで Action をディスパッチする。
また、 Store からデータを取得するため、 `@Select` デコレータをつけたプロパティを定義しておく。
Store から提供される値は Observable。

```typescript:src/app/training/training.service.ts
import { Injectable }       from '@angular/core';
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { Select, Store }    from '@ngxs/store';

import { Observable, Subscription } from 'rxjs';
import { take }                  from 'rxjs/operators';
import 'rxjs/add/operator/map';

import { Exercise }      from './exercise.model';
import { UIService }     from '../shared/ui.service';
import * as UI           from '../shared/ui.actions';
import { TrainingAction} from './training.actions';
import { TrainingState } from './training.state';

@Injectable()
export class TrainingService {
  @Select(TrainingState.getActiveTraining) activeTraining$: Observable<Exercise>;

  private fbSubs:             Subscription[] = [];

  constructor(
    private db:        AngularFirestore,
    private uiService: UIService,
    private store:     Store
  ) {}

  fetchAvailableExercises() {
    this.store.dispatch(new UI.StartLoading());
    this.fbSubs.push(this.db
    .collection('availableExercises')
    .snapshotChanges()
    .map(docArray => {
      // throw(new Error());
      return docArray.map(doc => {
        return {
          id: doc.payload.doc.id,
          ...doc.payload.doc.data() as Exercise
        };
      });
    })
    .subscribe((exercises: Exercise[]) => {
      this.store.dispatch(new UI.StopLoading());
      this.store.dispatch(new TrainingAction.SetAvailable(exercises));
    }, error => {
      this.store.dispatch(new UI.StopLoading());
      this.uiService.showSnackbar(
        'Fetching exercises failed, please try again later',
        null,
        3000
      );
    }));
  }

  startExercise(selectedId: string) {
    this.store.dispatch(new TrainingAction.Start(selectedId));
  }

  completeExercise() {
    this.activeTraining$.pipe(take(1)).subscribe(ex => {
      this.addDataToDatabase({
        ...ex,
        date:  new Date(),
        state: 'completed'
      });
      this.store.dispatch(new TrainingAction.Stop());
    });
  }

  cancelExercise(progress: number) {
    this.activeTraining$.pipe(take(1)).subscribe(ex => {
      this.addDataToDatabase({
        ...ex,
        duration: ex.duration * (progress / 100),
        calories: ex.calories * (progress / 100),
        date:     new Date(),
        state:    'cancelled'
      });
      this.store.dispatch(new TrainingAction.Stop());
    });
  }

  fetchCompleteOrCancelledExercises() {
    this.fbSubs.push(this.db
      .collection('finishedExercises')
      .valueChanges()
      .subscribe((exercises: Exercise[]) => {
        this.store.dispatch(new TrainingAction.SetFinished(exercises));
      }));
  }

  cancelSubscriptions() {
    this.fbSubs.forEach(sub => sub.unsubscribe());
  }

  private addDataToDatabase(exercise: Exercise) {
    this.db.collection('finishedExercises').add(exercise);
  }
}
```

その他、各コンポーネントでも `@Select` で値を切り取っていく。

# やってみた感想

一度 NgRx で組んでいれば、置き換えは思ったよりも楽だった。
途中エラーにも遭遇したが、 `static` キーワードが抜けていたためだったようだ。

また、NGXS の方がスッキリ書ける印象。
NgRx は select を使う際に selector をロジック内で書き込む必要があるが、 NGXS はそれをロジック外に書けるので、ロジック部分の見た目がスッキリできる。
(書いてみたら実感できると思う)

コード量については、あまり変わらないと思う。
NgRx の `reducer.ts` が、そのまま NGXS の `state.ts` に移せる感じだ。

# 今後の学習方針

Action 定義を省略する方法として、 ngxs-labs/emitter というものがあるらしいので、こちらをアプリに導入していこうと思う。

https://github.com/ngxs-labs/emitter