NGXS とは
NGXSは、Angular 向けの状態管理ライブラリです。
Angular 向け状態管理ライブラリではNgRxが広く使われていますが、NGXS は NgRx よりもさらにシンプルなコーディングを目指したライブラリです。
クラスやデコレータを活用することで、より簡単に、Redux ライクな状態管理を実現することができます。
NGXS の主な概念として、以下の 4 つがあります。
- Store:グローバルな状態コンテナであり、Action のディスパッチや状態の Select も担います。
- Actions:状態を変化させる Action とそのメタデータを定義するクラスです。
- State:状態を定義するクラスです。
- Selects:状態の特定の断面を取り出す関数です。
利用者(コンポーネントやサービスなど)から Store に対して Action をディスパッチすることで Store 内の状態が変化し、Select を通じて利用者は状態を参照することができます。
Tour of Heroes に NGXS を組み込んでみる
Angular 公式チュートリアル「Tour of Heroes」に NGXS を組み込んでみます。
チュートリアルでは、各コンポーネントと HeroService(API からデータを取得するサービス)が直接やりとりしていましたが、その間に NGXS Store を入れてみることにします。
元々は HeroService から受け取ったデータをコンポーネント自身が保持していましたが、Store が集約して状態を管理することによって、コンポーネントはデータの表示と入力の受付のみに専念することができます。
また、同じデータを複数のコンポーネントが参照しているような場合であっても、それぞれのコンポーネントがデータを取得しに行くことなく、Store から提供されるデータを見るだけで常に同期が取ることができます。
NGXS のインストール
まず NGXS を npm install します。
npm install @ngxs/store --save
Action を定義する
状態を変化させる操作、Action を定義します。
ここではヒーローのリストを状態として管理したいので、以下のクラスを定義することにしましょう。
- Add(追加)
- Delete(削除)
- Update(更新)
- Get(特定の 1 件を取得)
- GetAll(全件取得)
Action を記述するためのファイルとしてhero.actions.ts
を作成します。
ひとつの状態に対して複数の Action がある場合、namespace
などでまとめておくと import が楽になります。
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
)に定義します。
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()
で初期化します。
// 略
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 パイプなどを使うとよいでしょう。
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));
}
}
<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 を使うように修正してみます。
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();
});
}
}
<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>
結果
見た目は変わりませんね。当たり前ですね。
まとめ
- NGXS を使うと比較的簡単に Redux ライクな状態管理を導入できます。
- Redux とか NgRX とかちょっと見てウエッてなっちゃった人には NGXS おすすめです。
- クラスとかデコレータ使ってるのが Angular っぽくてイイなと思いました。