続・Schematics で始める @ngrx/store (@ngrx/effects篇)

前回の続き。

tercel-s.hatenablog.jp

今回は、Schematics で始める @ngrx/effects のお話。

この記事の対象読者は主に自分であり、前提となる説明やら手順やらもろもろ省いている。よく分かっていないところは逃げている。

そのため、何かの間違いでこの記事に辿り着いてしまった自分以外の誰かにとっては何一つとして得るものはない。

まえがき

前回書き忘れたが、Redux には三大原則呼ばれる掟がある。

  1. Single source of truth
  2. State in read-only
  3. Changes are made with pure functions

この中で(個人的に)最も大事だと思うのは、以下の2点である。

  • Reducer は state を変更できる唯一の存在である
    • Angular の Component や Service から直接 state を変更してはならない
  • Reducer は副作用を持ってはならない
    • 引数を書き換えてはならない
    • 外部の API を呼び出してはならない
    • 純粋関数ではない関数を呼び出してはならない(Math.random()などもってのほか)

ただし、現実問題として、クライアントとサーバ間の通信は REST API で実装せざるを得ず、さらにサーバ側からの応答は入力に対して冪等である保障は一切無い。

従って、上記の指針を実践的な Web アプリケーションの設計に忠実に適用するとなると難しい。

このあたりのジレンマは世の中の頭の良い人たちが散々検討を重ねてきて、とりあえず Angular の世界では @ngrx/effects が有効な解決策と言われている。

前置きだけでだらだら長くなったが、@ngrx/effects では、非同期処理やそれに伴う副作用を捌くための Effect という登場人物が新たに加わる。

Effect とは

コンポーネント(UI)などからディスパッチされた Action をひたすら監視し、その Action の種別に応じて副作用のある処理を実行する役割を担う。

また、処理の結果に応じて新たな Action をディスパッチすることもできる。

  • Effect には state を直接更新する権限はない(必ず Reducer に state の更新を依頼する)。
  • Reducer には副作用のある処理を書かず、Effect に集約する

これによって、先ほど挙げた問題点が解消できる。

自分用チュートリアル

前回の状態からスタート。

環境

  • node 8.11.3
  • npm 5.6.0
  • @angular/cli 6.1.2

必要なライブラリのインストール

コマンドプロンプト(VSCの統合ターミナルでも可)を起動し、package.json のあるフォルダに $cd で移動し、以下のコマンドを入力する。

$npm i @ngrx/effects

これで、@ngrx/effects ライブラリがプロジェクトにインストールされる。

Schmatics を用いた Effect の作成

続いて、以下のコマンドを入力する。

$ng generate @ngrx/schematics:effect App --root --module app.module.ts

すると、以下のような Effect の雛形が生成される。

src/app/app.effects.ts

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';


@Injectable()
export class AppEffects {

  constructor(private actions$: Actions) {}
}

今回は簡単のため、このファイルをそのまま編集する。

Effect の実装

改めて、Effect で行うべきことは以下3点である。

  1. 特定の Action の発行を捕捉する
  2. (特定のActionが発行されたタイミングで)任意の非同期サービスを実行する
  3. サービスの実行結果に応じて、新たな Action を発行する

例題として、IncrementCounter Action が発行されると、「0.5 秒後に DecrementCounter Action を生成する」 Effect を書くことにしよう。

src/app/app.effects.ts

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

import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';

import { Observable, interval } from 'rxjs';
import { map, take, concatMap } from 'rxjs/operators';

import {
  CounterActionTypes,
  IncrementCounter,
  DecrementCounter
} from './actions/counter.actions'

@Injectable()
export class AppEffects {

  constructor(private actions$: Actions) { }

  @Effect()
  increment$: Observable<Action> = this.actions$.pipe(
    ofType<IncrementCounter>(CounterActionTypes.Increment),
    // map(action => action.payload)
    // ↑ payloadを持つActionの場合、こう書くとpayloadを取り出せる
    concatMap(action => {
      return interval(500).pipe(
        take(1),
        map(() => new DecrementCounter())
      )
    })
  );
}

自動生成されるコードを見て解る通り、Effect には Actions が DI される。 これは、画面(および Effect)からディスパッチされたすべての Action が通るストリームなので、引数の識別子の末尾には慣例に従って $ マークがついている。

ここから ofType() で Action の種別Typeを文字列型で指定すると、当該種別の Action のみをフィルタリングできる。こうして IncrementCounter Action に絞った上で、concatMapmergeMap か、あるいは switchMap のいずれかを連結させ、その中に非同期呼び出しを書く。

concatMap, mergeMap, switchMap の違いについては、Qiita のこの記事が詳しい。要するに以下の通り。

  • concatMap は、非同期処理の発行順に結果が返る
  • mergeMap は、非同期処理の解決順に結果が返る
  • switchMap は、非同期処理の解決前に次の処理が流れてくると、前の処理はキャンセルされる

ひとまずはこれで $ng serve を打ち、画面の [+] ボタンを連打すると想定した動きになる。

よくある質問を自分で聞いて自分で答えるコーナー

  • Q. 非同期サービスの引数や、実行した結果はどうやって Store に連携すればよいか?
    • A. それらの情報は Action の payload に格納する。 Reducer は、Action の payload に基づいて新たな State を生成すればよい。
  • Q. Effect の中で、ある Action の処理の中で全く同じ Action を発行するとどうなる?
    • A. 普通に無限ループになる。