10
6

More than 3 years have passed since last update.

NGXSではじめるAngularアプリの状態管理

Last updated at Posted at 2020-06-14

NGXS とは

NGXSは、Angular 向けの状態管理ライブラリです。
Angular 向け状態管理ライブラリではNgRxが広く使われていますが、NGXS は NgRx よりもさらにシンプルなコーディングを目指したライブラリです。
クラスやデコレータを活用することで、より簡単に、Redux ライクな状態管理を実現することができます。

NGXS の主な概念として、以下の 4 つがあります。

  • Store:グローバルな状態コンテナであり、Action のディスパッチや状態の Select も担います。
  • Actions:状態を変化させる Action とそのメタデータを定義するクラスです。
  • State:状態を定義するクラスです。
  • Selects:状態の特定の断面を取り出す関数です。

diagram.png

利用者(コンポーネントやサービスなど)から Store に対して Action をディスパッチすることで Store 内の状態が変化し、Select を通じて利用者は状態を参照することができます。

Tour of Heroes に NGXS を組み込んでみる

Angular 公式チュートリアル「Tour of Heroes」に NGXS を組み込んでみます。
チュートリアルでは、各コンポーネントと HeroService(API からデータを取得するサービス)が直接やりとりしていましたが、その間に NGXS Store を入れてみることにします。
元々は HeroService から受け取ったデータをコンポーネント自身が保持していましたが、Store が集約して状態を管理することによって、コンポーネントはデータの表示と入力の受付のみに専念することができます。
また、同じデータを複数のコンポーネントが参照しているような場合であっても、それぞれのコンポーネントがデータを取得しに行くことなく、Store から提供されるデータを見るだけで常に同期が取ることができます。

diagram2.png

NGXS のインストール

まず NGXS を npm install します。

npm install @ngxs/store --save

Action を定義する

状態を変化させる操作、Action を定義します。
ここではヒーローのリストを状態として管理したいので、以下のクラスを定義することにしましょう。

  • Add(追加)
  • Delete(削除)
  • Update(更新)
  • Get(特定の 1 件を取得)
  • GetAll(全件取得)

Action を記述するためのファイルとしてhero.actions.tsを作成します。
ひとつの状態に対して複数の Action がある場合、namespaceなどでまとめておくと import が楽になります。

hero.actions.ts
import { Hero } from './hero';

export namespace HeroAction {
  export class Add {
    static readonly type = '[Hero] Add';
    constructor(public hero: Hero) {}
  }

  export class Delete {
    static readonly type = '[Hero] Delete';
    constructor(public hero: Hero | number) {}
  }

  export class Update {
    static readonly type = '[Hero] Update';
    constructor(public hero: Hero) {}
  }

  export class Get {
    static readonly type = '[Hero] Get';
    constructor(public id: number) {}
  }

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

各 Action クラスは、Action の ID となるtypeプロパティを持ちます。
Add、Delete など、対象データを特定するための引数を取る場合は、コンストラクタ引数に指定します。

State を定義する

状態をあらわす State を定義します。
ここでは State クラスを記述するためにhero.state.tsを作成します。
保持するデータとして、ヒーローのリストであるheroesと、ヒーロー詳細画面に表示するためのselectedHeroを状態モデル(HeroStateModel)に定義します。

hero.state.ts
import { Injectable } from '@angular/core';
import { State, Action, StateContext, Selector } from '@ngxs/store';
import { Hero } from './hero';
import { HeroAction } from './hero.actions';
import { HeroService } from './hero.service';
import { tap, finalize } from 'rxjs/operators';

export class HeroStateModel {
  heroes: Hero[];
  selectedHero: Hero;
}

@State<HeroStateModel>({
  name: 'heroes',
  defaults: {
    heroes: [],
    selectedHero: null,
  },
})
@Injectable()
export class HeroState {
  constructor(private heroService: HeroService) {}

  @Action(HeroAction.GetAll)
  getHeroes(ctx: StateContext<HeroStateModel>) {
    return this.heroService.getHeroes().pipe(
      tap((data) => {
        ctx.patchState({ heroes: data });
      })
    );
  }

  @Action(HeroAction.Get)
  getHero(ctx: StateContext<HeroStateModel>, action: HeroAction.Get) {
    return this.heroService.getHero(action.id).pipe(
      tap((data) => {
        ctx.patchState({ selectedHero: data });
      })
    );
  }

  @Action(HeroAction.Add)
  addHero(ctx: StateContext<HeroStateModel>, action: HeroAction.Add) {
    return this.heroService.addHero(action.hero).pipe(
      finalize(() => {
        ctx.dispatch(new HeroAction.GetAll());
      })
    );
  }

  @Action(HeroAction.Delete)
  deleteHero(ctx: StateContext<HeroStateModel>, action: HeroAction.Delete) {
    return this.heroService.deleteHero(action.hero).pipe(
      finalize(() => {
        ctx.dispatch(new HeroAction.GetAll());
      })
    );
  }

  @Action(HeroAction.Update)
  updateHero(ctx: StateContext<HeroStateModel>, action: HeroAction.Update) {
    return this.heroService.updateHero(action.hero).pipe(
      finalize(() => {
        ctx.patchState({
          selectedHero: action.hero,
        });
      })
    );
  }

  @Selector()
  static heroes(state: HeroStateModel) {
    return state.heroes;
  }

  @Selector()
  static selectedHero(state: HeroStateModel) {
    return state.selectedHero;
  }
}

@Stateデコレータは、後ろに記述されるクラスが State クラスであることを示し、型引数として渡された状態モデルのデータを保持します。
また、メタデータとして、Store の中での ID となるnameプロパティや、状態の初期値であるdefaultsプロパティを取ります。

State クラスの中では、@Actionデコレータや@Selectorを付けたメソッドを記述することができます。
@Action デコレータが付いているメソッドは状態の更新ロジック、@Selector デコレータが付いているメソッドが状態の取得ロジックと考えるとよいでしょう。
@Action デコレータに Action クラスを指定することで、どの Action に対応するメソッドなのかをあらわすことができます。
また Action に引数がある場合、メソッドの第二引数として渡されてきます。

Store モジュールを import する

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

app.module.ts(抜粋)
// 略
import { NgxsModule } from '@ngxs/store';
import { HeroState } from './hero.state';
// 略

@NgModule({
  imports: [
    // 略
    NgxsModule.forRoot([HeroState]),
  ],
  declarations: [
    // 略
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

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

ヒーロー一覧画面(heroes コンポーネント)のコンポーネントロジックを修正します。
HeroService の代わりに@ngxs/storeを import し、getHeroes()add()delete()の各メソッドで Action をディスパッチします。
また、Store からデータを取得するため、@Selectデコレータを付けたheroes$プロパティを定義しておきます。
Store から提供される値は Observable なので、ビューに反映させるには async パイプなどを使うとよいでしょう。

heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { Store, Select } from '@ngxs/store';
import { HeroAction } from '../hero.actions';
import { HeroState } from '../hero.state';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css'],
})
export class HeroesComponent implements OnInit {
  @Select(HeroState.heroes) heroes$: Observable<Hero[]>;

  constructor(private store: Store) {}

  ngOnInit() {
    this.getHeroes();
  }

  getHeroes(): void {
    this.store.dispatch(new HeroAction.GetAll());
  }

  add(name: string): void {
    name = name.trim();
    if (!name) {
      return;
    }
    this.store.dispatch(new HeroAction.Add({ name } as Hero));
  }

  delete(hero: Hero): void {
    this.store.dispatch(new HeroAction.Delete(hero));
  }
}
heroes.component.html
<ul class="heroes">
  <li *ngFor="let hero of heroes$ | async">
    <a routerLink="/detail/{{ hero.id }}">
      <span class="badge">{{ hero.id }}</span> {{ hero.name }}
    </a>
    <button class="delete" title="delete hero" (click)="delete(hero)">x</button>
  </li>
</ul>

ヒーロー詳細画面(hero-detail コンポーネント)のコンポーネントロジックも同様に Store を使うように修正してみます。

hero-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { Hero } from '../hero';
import { Store, Select } from '@ngxs/store';
import { HeroAction } from '../hero.actions';
import { HeroState } from '../hero.state';
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';

@Component({
  selector: 'app-hero-detail',
  templateUrl: './hero-detail.component.html',
  styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
  @Select(HeroState.selectedHero) hero$: Observable<Hero>;

  constructor(
    private route: ActivatedRoute,
    private location: Location,
    private store: Store
  ) {}

  ngOnInit() {
    this.getHero();
  }

  getHero(): void {
    const id = +this.route.snapshot.paramMap.get('id');
    this.store.dispatch(new HeroAction.Get(id));
  }

  goBack(): void {
    this.location.back();
  }

  save(): void {
    this.hero$.pipe(first()).subscribe((hero) => {
      this.store.dispatch(new HeroAction.Update(hero));
      this.goBack();
    });
  }
}
hero-detail.component.html
<div *ngIf="hero$ | async as hero">
  <h2>{{ hero.name | uppercase }} Details</h2>
  <div><span>id: </span>{{ hero.id }}</div>
  <div>
    <label
      >name:
      <input [(ngModel)]="hero.name" placeholder="name" />
    </label>
  </div>
  <button (click)="goBack()">go back</button>
  <button (click)="save()">save</button>
</div>

結果

20200614.gif

見た目は変わりませんね。当たり前ですね。

まとめ

  • NGXS を使うと比較的簡単に Redux ライクな状態管理を導入できます。
  • Redux とか NgRX とかちょっと見てウエッてなっちゃった人には NGXS おすすめです。
  • クラスとかデコレータ使ってるのが Angular っぽくてイイなと思いました。

参考

ngxs.io
Angular の状態管理ライブラリ 「NGXS」に入門する

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6