NgRx Actionを命令ではなく出来事として設計させるレビュー

NgRx を導入した直後のコードには、loadUserssaveOrderdeleteItem のような Action が並びやすい。名前は動詞で、呼べば処理が始まる。Action を「処理を起動するための命令」として捉えると、この形に落ち着く。

この捉え方のまま進めると、Action の数が増えたところで追跡できなくなる。NgRx の命名規約が [Source] Event という形をしているのは、Action が起きた出来事を表すものだからだ。

起点を兼ねたAction

次の Action は、画面の初期化とEffectからの再試行の両方で使われている。

起点を兼ねている実装
export const loadUsers = createAction('[Users] Load Users');

// 画面側
ngOnInit(): void {
  this.store.dispatch(loadUsers());
}

// Effect 側
retryOnFailure$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserActions.loadFailed),
    delay(3000),
    map(() => loadUsers()),
@Reviewer
画面の初期化と再試行が同じActionを使っています。`loadUsers` に反応する処理が増えると、どちらの起点で走ったのかを区別できなくなります。
), );

loadUsers に反応する Effect が1つのうちは問題にならない。ローディング表示を出す Reducer と、分析イベントを送る Effect が加わった時点で状況が変わる。

再試行のたびに初期化と同じ経路をたどるため、利用者が操作していないのにローディング表示が出る。分析イベントも、画面を開いた回数ではなく再試行の回数を数えることになる。どちらも Action が起点の情報を持っていないために起きる。

起点ごとに Action を分ければ、反応する側が選べるようになる。

起点を分けた実装
export const UserPageActions = createActionGroup({
  source: 'Users Page',
  events: {
    'Opened': emptyProps(),
    'Retry Clicked': emptyProps(),
  },
});

export const UserApiActions = createActionGroup({
  source: 'Users API',
  events: {
    'Load Succeeded': props<{ users: User[] }>(),
    'Load Failed': props<{ error: string }>(),
  },
});

Effect 側では、読み込みという処理をまとめたうえで、どの起点に反応するかを明示する。

起点を選んで反応する
loadUsers$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserPageActions.opened, UserPageActions.retryClicked),
    switchMap(() =>
      this.api.fetchUsers().pipe(
        map(users => UserApiActions.loadSucceeded({ users })),
        catchError(({ message }) => of(UserApiActions.loadFailed({ error: message }))),
      ),
    ),
  ),
);

処理の重複を避けたいだけなら ofType に複数指定すればよく、Action をひとつにまとめる必要はない。

Comment
@Reviewer: このActionは画面の初期化とEffectの再試行の両方から発火されています。ローディング表示の条件を変えたくなったときに分けられなくなるため、起点ごとに分けておきませんか。

Sourceが指すもの

[Source] Event の Source は、その Action を定義したファイルの場所でも、担当する機能名でもない。その出来事がどこで起きたかを指す。

Source の例 何を表すか
[Users Page] 利用者がその画面で何かをした
[Users API] APIの呼び出しが成功または失敗した
[Auth Guard] ガードの判定という仕組み側の出来事が起きた

Source が [Users] のように機能名だけになっている場合、そこには起点の情報が入っていない。デバッグツールで Action の並びを見たときに、利用者の操作なのかシステムの反応なのかが読み取れない。

Event の側も、Load Users のような命令形ではなく OpenedLoad Succeeded のように起きたことの形にする。命令形のままだと、複数の起点から同じ命令を出せてしまい、起点を兼ねた Action に戻っていく。

Comment
@Reviewer: `[Data] Load Data` の Source が機能名になっています。どの画面、どのAPIで起きた出来事なのかが分かる名前にできますか。

粒度を決める3つの区分

Action の粒度は、出来事の種類で分けると揃えやすい。

  1. 利用者の操作。画面を開いた、ボタンを押した、入力を変えた
  2. 外部からの結果。APIが成功した、失敗した、タイムアウトした
  3. 仕組み側の出来事。ルーティングが起きた、認証が切れた、接続が復帰した

この3つを別の Action として持つと、Reducer と Effect の担当も自然に分かれる。状態の更新は結果の Action に反応させ、通信の開始は操作の Action に反応させる形になる。

テストの書きやすさも変わる。起点が分かれていれば、「画面を開いたとき」と「再試行したとき」を別のケースとして書ける。ひとつの Action を共有していると、テスト側でも起点を作り分けられない。

増やしすぎを避ける基準

起点ごとに分けると Action の数は増える。どこまで分けるかの基準がないと、今度は定義ファイルが読めなくなる。

分ける必要があるのは、反応する側が区別したい場合に限られる。同じ処理しか起きないなら、ひとつの Action で足りる。

判断の順序は次のようになる。

  1. その Action に反応する処理は何種類あるか
  2. 起点によって反応を変えたい処理があるか
  3. あるなら分ける。無いならまとめたままにする

将来分けたくなる可能性だけを根拠に増やすと、使われない Action が残る。実際に区別が必要になった時点で分けるほうが、定義と使用が対応した状態を保ちやすい。

レビュー観点チェックリスト

Actionを見たときの確認項目
  • Action 名が起きた出来事を表しているか。命令形になっていないか
  • Source が起点を指しているか。機能名やファイルの場所になっていないか
  • ひとつの Action が複数の起点から発火されていないか
  • 起点を兼ねた結果、反応する側が区別できなくなっていないか
  • 利用者の操作、外部からの結果、仕組み側の出来事が混ざっていないか
  • 反応する処理が1種類しかない Action を、先回りして分けていないか

おわりに

Action を命令として書いても NgRx は動く。動くからこそ、設計の差が出るのは機能が増えた後になる。

レビューで確認できるのは、その Action を読んだときに「誰が何をしたのか」が分かるかどうかである。分からないなら、後から追跡する人も分からない。名前を変えるコストは、反応する側が増えるほど上がっていく。