NgRx SelectorをEffect内のcombineLatestで代用しない理由
NgRx SelectorをEffect内のcombineLatestで代用しない理由
Store から2つの状態を取り出して組み合わせたいとき、Effect の中で combineLatest を使う書き方がある。RxJS に慣れているほど自然に出てくる。
exportReport$ = createEffect(() =>
this.actions$.pipe(
ofType(ReportActions.exportRequested),
switchMap(() =>
combineLatest([
this.store.select(selectFilters),
this.store.select(selectVisibleRows),
]).pipe(
take(1),
map(([filters, rows]) => ReportActions.exportReady({ filters, rows })),
),
),
@ReviewerどちらもStoreの状態なので、Selectorを合成すれば1つの `select` で取得できます。組み合わせの定義がEffectの中に埋もれると、他から再利用できません。 ),
);NgRx の ESLint プラグインには avoid-combining-selectors というルールがある。Store の状態同士を組み合わせるなら Selector を合成せよ、という指針である。
合成したSelectorが持つもの
Selector を合成すると、組み合わせの定義が Store 層に置かれる。
export const selectExportPayload = createSelector(
selectFilters,
selectVisibleRows,
(filters, rows) => ({ filters, rows }),
);Effect 側は1つ選ぶだけになる。
exportReport$ = createEffect(() =>
this.actions$.pipe(
ofType(ReportActions.exportRequested),
concatLatestFrom(() => this.store.select(selectExportPayload)),
map(([, payload]) => ReportActions.exportReady(payload)),
),
);違いは3つある。
1つ目は再利用である。同じ組み合わせを画面からも使いたくなったとき、合成済みの Selector ならそのまま参照できる。Effect の中に書いた combineLatest は再利用できないので、同じ組み合わせを別の場所にもう一度書くことになる。
2つ目はメモ化である。createSelector の結果は、入力となる Selector の結果が変わらない限り再計算されない。combineLatest にこの仕組みはなく、どちらかが通知するたびに射影関数が走る。
3つ目はテストである。合成した Selector は入力を与えて出力を確かめるだけで検証できる。Effect の中に埋めると、Action の発火から確認することになる。
適用の範囲を取り違えない
このルールが言っているのは、Store の状態同士を組み合わせる場合の話である。あらゆる combineLatest を Selector へ置き換えよという意味ではない。
外部から届くストリーム、処理の途中にある Observable、フォームの値の変化などは Store の状態ではないため、combineLatest で組み合わせる。Selector に置き換えられるのは、両方が Store から取れる場合に限られる。
@Reviewer: この2つはどちらもStoreの状態です。Selectorを合成すれば結果がメモ化され、他の画面からも同じ組み合わせを参照できます。Selectorが長くなる原因
Selector を合成する方針にすると、今度は Selector 自体が長くなっていくことがある。
export const selectUsersWithPosts = createSelector(
selectAllUsers,
selectAllPosts,
(users, posts) =>
users.map(user => ({
...user,
posts: posts.filter(p => p.userId === user.id),
postCount: posts.filter(p => p.userId === user.id).length,
lastPostAt: Math.max(...posts.filter(p => p.userId === user.id).map(p => p.date)),
})),
);ここには種類の違う2つの問題が入っている。切り分けて扱う。
1つは同じ絞り込みを3回繰り返している点である。これは中間の Selector へ切り出せば解決する。
export const selectPostsByUserId = createSelector(
selectAllPosts,
posts => groupBy(posts, post => post.userId),
);
export const selectUsersWithPosts = createSelector(
selectAllUsers,
selectPostsByUserId,
(users, postsByUser) =>
users.map(user => {
const posts = postsByUser[user.id] ?? [];
return { ...user, posts, postCount: posts.length, lastPostAt: maxDate(posts) };
}),
);もう1つは、State が UI の必要とする形と噛み合っていない点である。投稿を利用者ごとに引きたい場面が繰り返し出るなら、State の側で正規化しておくほうが Selector は短くなる。
Selector が長いこと自体は原因ではなく兆候にあたる。切り出しで収まるのか、State の構造に戻る必要があるのかを見分けたい。
@Reviewer: この導出は State を正規化すれば不要になりませんか。Selector 側で構造の差を吸収している状態に見えます。メモ化が働かなくなる書き方
createSelector のメモ化は、入力の参照が同じであることを前提にしている。毎回新しい参照を返す射影があると、下流の Selector は毎回再計算される。
export const selectActiveUserIds = createSelector(
selectAllUsers,
users => users.filter(u => u.active).map(u => u.id),
);selectAllUsers が変わらなければこの Selector 自体はメモ化されるため、これは問題ない。注意が要るのは、この結果をさらに受け取る Selector が、毎回異なる配列を前提にする場合である。
もう一段見落としやすいのが、引数を取る Selector をテンプレートや描画のたびに作り直す形である。
// テンプレートから selectUserById(user.id) を直接呼んでいる
export const selectUserById = (id: string) =>
createSelector(selectUserEntities, entities => entities[id]);createSelector はメモ化の状態を自分の中に持つ。呼ぶたびに新しい Selector を作れば、その状態も毎回新しくなる。メモ化は働かない。
引数を取る Selector は、作った結果を保持して使い回す。あるいは、対象のIDを State 側に選択状態として持たせ、引数のない Selector にする。
なお、Store の Selector は既定でメモ化されるため、store.select(...) の後ろに distinctUntilChanged() を足す必要は通常ない。足してある箇所を見たら、メモ化が働いていない別の理由を疑うほうが早い。
@Reviewer: この Selector はテンプレートから呼ばれるたびに作り直されるため、メモ化が働きません。保持して使い回すか、選択中のIDを State に持たせる形にできませんか。レビュー観点チェックリスト
- Store の状態同士を Effect の中で
combineLatestしていないか - 逆に、Store の状態でないものまで Selector へ寄せようとしていないか
- 同じ絞り込みや集計が、ひとつの射影の中で繰り返されていないか
- Selector の長さが、State の構造の問題を吸収した結果になっていないか
- 引数を取る Selector を、描画のたびに作り直していないか
store.select(...)の後ろのdistinctUntilChanged()が、何かの回避策になっていないか
おわりに
combineLatest と Selector は、どちらも複数の値を組み合わせる。組み合わせる対象が時間軸上を流れる通知なのか、Store が持つ現在の状態なのかで、使う道具が決まる。
レビューで確認するのは、組み合わせている2つがどちらも Store から取れるかどうかである。取れるなら Selector に置き場所がある。取れないものが混ざっているなら、combineLatest のままでよい。