🐕

NGXS の基礎を理解する

2022/03/06に公開

NGXS の基礎理解

NGXS は Angular の状態管理ライブラリです。
Angular の状態管理というと NgRx を思い浮かべる人も多いかもしれませんが、
NGXS は NgRx よりもシンプルで簡便な状態管理を実現することができます。

https://www.ngxs.io/

NGXS を始める前に

NGXS を始める前に、基本的な以下のコンセプトについて理解していると理解が早いでしょう。

  • クラス デコレータの基礎
  • Redux のコンセプト
  • 基本的な rx.js の作法、Observable の扱い方

NGXS の構成要素

  • Store
  • Actions
  • State
  • Selects

各項目の関係図は、以下のサイトにも明記されています。

https://www.ngxs.io/concepts/intro

NGXS を始める

まずはシンプルな構成で、NGXS を利用したコードの流れを確認していきましょう。

今回はシンプルな todoリスト形式で、以下のようなサービスの DI で状態管理を行っているプロジェクトを
NGXS を利用した利用した状態管理へとシフトさせることを進めていきます。

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class TodoService {

  todolist = [
    {title:"牛乳を買いに行く",expire_at:"2022/01/05",priority:"high"},
    {title:"本を読み切る",expire_at:"2022/01/05",priority:"high"},
    {title:"部屋を片付ける",expire_at:"2022/01/05",priority:"normal"},
  ]

  constructor() { }

  add(form: any) {
    this.todolist.push({...form})
  }
}

こちらの service を利用したコードの確認は、以下の記事を参照してください。

https://zenn.dev/mikakane/articles/angular_1_tutorial

今回のサンプルコードは、以下のURLからも確認可能です。

https://stackblitz.com/edit/angular-ivy-abjxkn

環境構築

NGXS を始める場合、まずライブラリをインストールします。

npm install @ngxs/store

Actions の定義

ライブラリのインストールができたので、
まずは、状態管理で利用する Actions の定義から始めていきましょう。

Actions は状態に対するデータ操作の一覧定義です。
今回は、todo リストの取得と、todo リストの追加を定義しています。

todo.actions.ts
export namespace TodoAction {
  export class Add {
    static readonly type = '[Todo] Add';
    constructor(public todo: any) {}
  }

  export class GetAll {
    static readonly type = '[Todo] GetAll';
  }
}

NGXS では、コンポーネントから状態を操作する際に、
Actions で定義したクラスを用いて操作要求を発行します。

add など ペイロードを伴うような操作要求については、
TodoAction.Add のようにクラスコンストラクタに引数を定義して、
操作に必要なデータを格納できるようにしています。

State の定義

Action が定義できたら、次は State を定義します。
State は状態管理の対象であるデータの形式と、
そのデータに紐づくActions の実装を行う、状態管理の中核を担う要素です。

todo.state.ts
import { Injectable } from '@angular/core';
import { State, Action, StateContext } from '@ngxs/store';
import { TodoAction } from './todo.actions';

@State<any[]>({
  name: 'todos',
  defaults: [
    { title: '牛乳を買いに行く', expire_at: '2022/01/05', priority: 'high' },
    { title: '本を読み切る', expire_at: '2022/01/05', priority: 'high' },
    { title: '部屋を片付ける', expire_at: '2022/01/05', priority: 'normal' },
  ],
})
@Injectable()
export class TodoState {
  constructor() {}

  @Action(TodoAction.GetAll)
  getAll(ctx: StateContext<any[]>) {
    const state = ctx.getState();
    return state;
  }

  @Action(TodoAction.Add)
  addHero(ctx: StateContext<any[]>, action: TodoAction.Add) {
    const state = ctx.getState();
    state.push(action.todo);
    ctx.setState(state);
  }
}

クラスデコレータの @State は、 State を定義するデコレータです。
defaults で state の初期データを定義することができます。

@State<any[]>({
  name: 'todos',
  defaults: [
    { title: '牛乳を買いに行く', expire_at: '2022/01/05', priority: 'high' },
    { title: '本を読み切る', expire_at: '2022/01/05', priority: 'high' },
    { title: '部屋を片付ける', expire_at: '2022/01/05', priority: 'normal' },
  ],
})
@Injectable()
export class TodoState {
    // ...
}

State クラスの内部では、メソドを用いて State への操作を定義していきます。
getAll は state のデータを全量取得する操作を定義しています。
操作はすべて@Action デコレータを用いて、 Action と紐付ける形で実装されます。

ctx.getState は State のデータをそのまま取得することができる関数です。

export class TodoState {
  @Action(TodoAction.GetAll)
  getAll(ctx: StateContext<any[]>) {
    const state = ctx.getState();
    return state;
  }
  // ...
}

add はタスクの追加を定義しています。
getState で現在の state を取得して push で操作した後、
ctx.setState で新しい State への更新を実装しています。

export class TodoState {
  // ...
  @Action(TodoAction.Add)
  add(ctx: StateContext<any[]>, action: TodoAction.Add) {
    const state = ctx.getState();
    state.push(action.todo);
    ctx.setState(state);
  }
}

コンポーネントからの利用

コンポーネントからの参照は非常にシンプルです。

NGXS からのデータ参照は @Select を用いてプロパティを定義して行います。
今回 State の定義では 非同期処理を扱いませんでした、
@Selectによるバインドでは、暗黙的に Observable への変換が行われます。

import { Select } from '@ngxs/store';
import { Observable } from 'rxjs';
import { TodoState } from '../todo.state';

@Component({ 
  //...
})
export class TopPageComponent implements OnInit {
  @Select(TodoState) todolist$: Observable<any[]>;
  constructor() {}
  ngOnInit() {}
}

この作成した todolist$ をテンプレートで利用する際には、
async パイプを用いて以下のように実装できます。

<div class="divide-y">
  <div class="p-2" *ngFor="let todo of todolist$ | async">
    {{ todo.title }}
  </div>
</div>

State のデータ操作は、Store をDIで注入して dispatch 関数をコールすることで行われます。
Action クラスを自身で new して、dispatch 関数の引数に渡すことで、
該当の Action と紐づく State の処理が自動的に実施されます。

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngxs/store';
import { TodoAction } from '../todo.actions';

@Component({
    // ...
})
export class FormPageComponent implements OnInit {
  constructor(private store: Store, private router: Router) {}

  add() {
    this.store.dispatch(
        new TodoAction.Add({
            title: '引っ越しの見積もりを取る',
            expire_at: '2022-01-01',
            priority: 'high',
        })
    );
    this.router.navigateByUrl('/');
  }
}

非同期 Action の利用

ここでは簡潔に同期的な記述を行っていますが、
多くの Action 処理は非同期に実装されるはずです。

非同期 Action を実装する際には、Action の内部で以下のように Observable を return します。

import { timer } from 'rxjs';
import { tap } from 'rxjs/operators';
import { TodoAction } from './todo.actions';

// ... some decorator
export class TodoState {
  // ...
  @Action(TodoAction.Add)
  addHero(ctx: StateContext<any[]>, action: TodoAction.Add) {
    return timer(2000).pipe(
      tap((r) => {
        const state = ctx.getState();
        state.push(action.todo);
        ctx.setState(state);
      })
    );
  }
}

ここで非同期処理に作用を入れたい場合は、subscribeではなく pipe tapを利用するようにしてください。
subscribe で定義した作用は subscribe 内での処理が完了する前に dispatch 元にイベントが push されてしまい、
期待した動作を得られません。

コンポーネント側の イベント発火では、
dispatch の戻り値が observable になっているため、
subscribe して Action の完了を待機することができます。

import { Component, OnInit } from '@angular/core';
import { TodoAction } from '../todo.actions';

@Component({
    // ...
})
export class FormPageComponent implements OnInit {
    // ...
    add() {
        this.store
            .dispatch(
                new TodoAction.Add({
                    title: '引っ越しの見積もりを取る',
                    expire_at: '2022-01-01',
                    priority: 'high',
                })
            )
            .subscribe((r) => {
                this.router.navigateByUrl('/');
            });
    }
}

dispatch 自体の戻り値は observable となっており、
値として、変更された State が取得できるようになっています。

Select のヴァリエーション

NGXS において @Select はコンポーネントに状態を紐付けるために必要な記法です。

https://www.ngxs.io/concepts/select

基本の @Select 記法

Select の基本記法では、Select デコレータに State クラスを渡して、
Stateのデータ全体をコンポーネントで受け取ることができます。

以下の例では、animals Observable で、動物名一覧の配列を受け取る実装を行っています。

import { Select } from '@ngxs/store';
import { ZooState } from './zoo.state';

@Component({ ... })
export class ZooComponent {
  // Reads the name of the state from the state class
  @Select(ZooState) animals$: Observable<string[]>;
}

複雑な Select デコレータ

Select デコレータに関数を渡して、state のフィルタリングを実装することもできます。
フィルタ関数では、引数でアプリケーション全体の state が取得できます。

以下の例では、animals Observable で、zoo State クラスの state から animals キーのみを引き出しています。
ここで、zoo は State クラス宣言時に実装した State の name 要素です。

import { Select } from '@ngxs/store';
import { ZooState } from './zoo.state';

@Component({ ... })
export class ZooComponent {
  // Also accepts a function like our select method
  @Select(state => state.zoo.animals) animals$: Observable<string[]>;
}

上記の Select 処理はコンポーネントにとって便利な半面、 デコレータの定義が複雑になりがちです。

余裕があれば、State のメモ機能の利用を検討してください。

Select 処理のメモ化

上記のような State のフィルタ処理は、
State クラス側で static に宣言して簡便に呼び出すことができます。

これは NGXS の Memoized Selectors と呼ばれる機能で、
Memoized Selectors は、State 内で Selector デコレータを利用して static 関数で実装します。

import { Injectable } from '@angular/core';
import { State, Selector } from '@ngxs/store';

@State<string[]>({
  name: 'animals',
  defaults: []
})
@Injectable()
export class ZooState {
  @Selector()
  static pandas(state: string[]) {
    return state.filter(s => s.indexOf('panda') > -1);
  }
}

Memoized Selectors を利用する際には、
Memoized Selectors の関数実装をコンポーネントの Select デコレータに渡します。

import { Select } from '@ngxs/store';
import { ZooState, ZooStateModel } from './zoo.state';

@Component({ ... })
export class ZooComponent {
  // Uses the pandas memoized selector to only return pandas
  @Select(ZooState.pandas) pandas$: Observable<string[]>;
}

プログラムによる state 操作

Selector デコレータを用いずとも、
Store を DI して、 store オブジェクトから select 関数で値を取得することも可能です。

select 関数の引数となる関数では、第一引数でアプリケーション全体の state を取得でき、
各 State クラスの name 要素を用いて、各 State クラスの状態にアクセスできます。

import { Store } from '@ngxs/store';

@Component({ ... })
export class ZooComponent {
  animals$: Observable<string[]>;

  constructor(private store: Store) {
    this.animals$ = this.store.select(state => state.zoo.animals);
  }
}

State の Snapshot を取得する

Store クラスの DI で State を操作する際には、
select 関数の他に、selectSnapshot 関数を用いることも可能です。

selectSnapshot 関数では、Observable ではなく単順な値として、
実行時の State の値そのものを受け取ることが可能です。

@Injectable()
export class JWTInterceptor implements HttpInterceptor {
  constructor(private store: Store) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = this.store.selectSnapshot<string>((state: AppState) => state.auth.token);
    req = req.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });

    return next.handle(req);
  }
}

Discussion