Angular上級 #5 NgRx 入門 — Store、Action、Reducer、Effect

読了 10分

前回 は RxJS の深さ — operator の肌理を一度覗き込みました。今回はその上に乗る 状態管理ライブラリNgRx です。

基礎 #5 Service/DI で見たように、Angular は別のライブラリなしでも Service + Signals だけで小さなアプリの状態をかなりすっきりと扱えます。実際多くのアプリではそれが正解です。それでも NgRx を別に 1 つの講義として扱う理由は、アプリが一定の規模を超える瞬間に「状態がどこでどう変わっているか」を追跡することそれ自体が別のツールを要求し始める からです。

今回は NgRx が登場した背景である Redux パターン から始まり、パッケージ構成、Store / Action / Reducer / Selector / Effect の役割、モダン standalone セットアップ、そしてより軽量な代替である Component StoreSignalStore まで巡ります。

Service + Signal で足りなくなる地点 #

基礎 #5 で作ったあのパターン — providedIn: 'root' Service に signal + computed、メソッドでのみ変更を許す小さな store — は、小さなアプリでは既に十分です。問題が始まる地点はおおむね次のようなものです。

  • 同じデータを 複数の feature がそれぞれ異なる形 で見るようになる
  • 「この値を誰がいつ変えたか?」の追跡ができない — 複数のコンポーネントが直接 update を呼ぶため
  • 非同期の流れ(ロード/エラー/キャンセル)が 1 つのページに同時に複数表示されている

ここまで来ると 「状態変更を 1 箇所に集め、その変更に名前を付け、流れをログ/タイムトラベルで覗き込めるツール」 が必要になります。それが Redux で、Angular 版が NgRx です。

Redux パターンを 1 度 — 3 つの原則 #

  1. Single source of truth — アプリ全体の状態を 1 つの store の中に置く
  2. Immutable state — 状態は直接修正せず 常に新しいオブジェクトに置き換える
  3. 単方向の流れ — コンポーネントが Action を dispatch すれば、Reducer が新しい state を作り、コンポーネントは Selector を通じて読むだけ

流れを 1 つの絵で見るとこのようになります。

NgRx 単方向の流れ
Component ──dispatch(Action)──► Store ──► Reducer(prev, action) ──► newState
   ▲                              │                                    │
   └──── select(Selector) ────────┴────────────────────────────────────┘

非同期の副作用(API 呼び出し、ルーティング)が割り込むところが Effect です。Effect はアクションを聞いて → 非同期作業を遂行し → 結果をまた別のアクションとして dispatch します。副作用さえも 「アクション → アクション」 という同じ言語で表現されます。この一貫性が大きなアプリで光る理由です。

NgRx パッケージ構成 #

NgRx はひとかたまりではなく、複数のパッケージに分かれています。必要な分だけ選んで使います。

  • @ngrx/store — Redux スタイルのグローバル store。Action / Reducer / Selector
  • @ngrx/effects — 非同期副作用をアクションの流れで扱うツール
  • @ngrx/entity — コレクション状態(リスト、id マッピング)のためのアダプタ。CRUD ボイラープレートを減らす
  • @ngrx/component-store — feature/コンポーネント単位の軽量なローカル store
  • @ngrx/signals — モダン Angular 向けの新しいモデル。SignalStore。signal フレンドリーな設計
  • @ngrx/router-store — ルーター状態を store に同期

インストールは ng add @ngrx/store@latestng add @ngrx/effects@latestng add が standalone セットアップまで一緒にやってくれます。この記事では store + effects を中心に置いて流れを見て、最後に Component Store と SignalStore を短く比較します。

サンプルドメイン — Todos #

本格的に始める前にドメインを 1 つ決めておきます。やること(todo) リスト — ユーザーが todo を追加/削除し、サーバーから最初のリストを受け取ります。

src/app/todos/todos.state.ts
export interface Todo { id: number; title: string; done: boolean; }

export interface TodosState {
  items: Todo[];
  loading: boolean;
  error: string | null;
}

export const initialTodosState: TodosState = {
  items: [],
  loading: false,
  error: null,
};

Action — 何が起きたかに名前を付ける #

Action は「何が起きたか」を表現する普通のオブジェクトです。createAction ヘルパーで型と payload をすっきり定義します。

src/app/todos/todos.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from './todos.state';

export const loadTodos = createAction('[Todos] Load');
export const loadTodosSuccess = createAction(
  '[Todos] Load Success',
  props<{ items: Todo[] }>(),
);
export const loadTodosFailure = createAction(
  '[Todos] Load Failure',
  props<{ error: string }>(),
);

export const addTodo = createAction('[Todos] Add', props<{ title: string }>());
export const removeTodo = createAction('[Todos] Remove', props<{ id: number }>());

[Todos] プレフィックスは DevTools のログでどこで起きたアクションかを一目で見るためのコンベンション です。[画面/feature 名] 動詞 の形が標準で、props<{ ... }>() がペイロードの型を打ち込んでくれるので、reducer/effect/component のどこでアクションを扱っても 型が最後まで追従します

Reducer — 新しい state を作る #

Reducer は (以前の state, action) => 新しい state 形式の純粋関数です。

src/app/todos/todos.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialTodosState } from './todos.state';
import * as TodosActions from './todos.actions';

export const todosReducer = createReducer(
  initialTodosState,

  on(TodosActions.loadTodos, (state) => ({ ...state, loading: true, error: null })),
  on(TodosActions.loadTodosSuccess, (state, { items }) => ({
    ...state, loading: false, items,
  })),
  on(TodosActions.loadTodosFailure, (state, { error }) => ({
    ...state, loading: false, error,
  })),

  on(TodosActions.addTodo, (state, { title }) => ({
    ...state,
    items: [...state.items, { id: Date.now(), title, done: false }],
  })),
  on(TodosActions.removeTodo, (state, { id }) => ({
    ...state,
    items: state.items.filter((t) => t.id !== id),
  })),
);

ポイントは immutable な更新 です。state.items.push(...) のように mutate せずに 常に spread で新しいオブジェクト/新しい配列 を作って返します。OnPush とシグナルが変更検知に使うのがすべて 参照比較 だという点を思い出せば、自然なルールです。

注記
NgRx 8 以前は reducer を巨大な switch 文で書きました。今は createReducer + on() の組み合わせが標準で、新しいコードならわざわざ古いスタイルに従う必要はありません。内部的には同じですが、型推論と可読性がはるかに優れています。

Selector — コンポーネントは select でだけデータを見る #

コンポーネントが store からデータを取り出すときは、直接 state.todos.items と読まずに Selector という小さな関数を経由します。この一層が 2 つのことをしてくれます — 状態の形が変わっても selector だけ直せば コンポーネントは触らなくてよく、memoization で入力が同じなら以前の結果をそのまま返します。

src/app/todos/todos.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodosState } from './todos.state';

export const selectTodosState = createFeatureSelector<TodosState>('todos');

export const selectTodos = createSelector(selectTodosState, (s) => s.items);
export const selectLoading = createSelector(selectTodosState, (s) => s.loading);

export const selectRemainingCount = createSelector(
  selectTodos,
  (items) => items.filter((t) => !t.done).length,
);

createSelector は上の selector の出力が 参照上同じなら 下の関数を再実行しません。派生計算が高くついても安全です。

Effects — 副作用をアクションの流れで #

Reducer は純粋関数なのでそこに HTTP 呼び出しを挟めません。API 呼び出し、ルーティング、ローカルストレージのような 副作用は Effects の出番 です。

src/app/todos/todos.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';

import { TodosApi } from './todos.api';
import * as TodosActions from './todos.actions';

@Injectable()
export class TodosEffects {
  private readonly actions$ = inject(Actions);
  private readonly api = inject(TodosApi);

  loadTodos$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodosActions.loadTodos),
      switchMap(() =>
        this.api.fetchAll().pipe(
          map((items) => TodosActions.loadTodosSuccess({ items })),
          catchError((err) =>
            of(TodosActions.loadTodosFailure({ error: err.message })),
          ),
        ),
      ),
    ),
  );
}

流れは 1 行で読めます — loadTodos アクションが入ってきたら API を呼び、結果を loadTodosSuccess または loadTodosFailure で再び dispatch します。コンポーネントはただ loadTodos を dispatch して selectLoading / selectTodos だけ見れば終わりです。

switchMap新しいリクエストが入ると以前のリクエストをキャンセル します。検索ボックスのように高速で連続呼び出しされるケースに自然で、前回の記事 の RxJS operator の分類がここでそのままつながります。

セットアップ — モダン standalone パターン #

NgRx も standalone 時代に合わせて provideStore / provideEffects 形式の関数型 provider を提供します。app.config.ts 1 箇所ですべて掴んでくれます。

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';

import { todosReducer } from './todos/todos.reducer';
import { TodosEffects } from './todos/todos.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideStore({ todos: todosReducer }),
    provideEffects([TodosEffects]),
    provideStoreDevtools({ maxAge: 50, connectInZone: false }),
  ],
};

feature が増えれば provideState('feature-name', featureReducer) をルート単位で付けて lazy load することもできます。大きなアプリでは最初から feature 単位で store を分けておく方がメンテナンスに有利です。

コンポーネントで — dispatch と select #

コンポーネントがすることは 2 つだけです。アクションを dispatch して、selector で state を見る

src/app/todos/todos-page.component.ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Store } from '@ngrx/store';

import * as TodosActions from './todos.actions';
import { selectLoading, selectRemainingCount, selectTodos } from './todos.selectors';

@Component({
  selector: 'app-todos-page',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (loading$ | async) {
      <p>読み込み中…</p>
    } @else {
      @for (todo of (todos$ | async) ?? []; track todo.id) {
        <p>{{ todo.title }} <button (click)="remove(todo.id)">削除</button></p>
      }
      <p>残りのやること: {{ remaining$ | async }}</p>
    }
  `,
})
export class TodosPageComponent {
  private readonly store = inject(Store);

  protected readonly todos$ = this.store.select(selectTodos);
  protected readonly loading$ = this.store.select(selectLoading);
  protected readonly remaining$ = this.store.select(selectRemainingCount);

  ngOnInit() { this.store.dispatch(TodosActions.loadTodos()); }
  remove(id: number) { this.store.dispatch(TodosActions.removeTodo({ id })); }
}

selector の結果は Observable なので async パイプで流しました。シグナルフレンドリーなコードを書きたいなら store.selectSignal(selectTodos) で受け取って signal() のように使うこともできます。OnPush とよくかみ合います。

Component Store — より軽く、より近い #

全体グローバル store が負担に感じられ、ただの Service よりはもう少し整頓されたツールが必要なときに使うのが @ngrx/component-store です。コンポーネントの providers に登録すると そのコンポーネントが生きている間だけ 生きているローカル store になります。ページを離れると一緒に消える、ローカル状態に馴染むモデル です。アクション/リデューサのボイラープレートなしに updater / effect メソッドだけで構成し、1 つのコンポーネントの一時状態(編集フォーム、ウィザードのステップなど)によく合います。

SignalStore — モダン Angular の新標準 #

最も最近に出たモデルが @ngrx/signals — 略して SignalStore です。signal を 1 級として置き、RxJS 依存を最小化した新しい設計です。アクション/リデューサ/セレクタを別途定義する必要はなく、状態/計算値/メソッド が 1 箇所に集まります。

src/app/todos/todos.signal-store.ts
import { computed, inject } from '@angular/core';
import {
  signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';

import { TodosApi } from './todos.api';
import { Todo } from './todos.state';

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as Todo[], loading: false, error: null as string | null }),
  withComputed(({ items }) => ({
    remaining: computed(() => items().filter((t) => !t.done).length),
  })),
  withMethods((store, api = inject(TodosApi)) => ({
    async load() {
      patchState(store, { loading: true, error: null });
      try {
        const items = await api.fetchAllAsPromise();
        patchState(store, { items, loading: false });
      } catch (err: any) {
        patchState(store, { error: err.message, loading: false });
      }
    },
    add(title: string) {
      patchState(store, ({ items }) => ({
        items: [...items, { id: Date.now(), title, done: false }],
      }));
    },
  })),
);

コンポーネントでは inject(TodosStore) で受け取って シグナルのようにそのまま 使います — store.items()store.loading()store.remaining()。モダン Angular の新しいプロジェクトなら、SignalStore を第 1 候補に置く流れが徐々に強まっています。

ただし DevTools のタイムトラベルAction 単位の監査ログ は依然としてクラシック NgRx の強みです。大きなチームで「どのアクションがどの順序で起こったか」を最後まで追跡しなければならない環境なら、そちら側がまだ重さを持ちます。

いつ NgRx を使うべきか — しきい値ガイド #

3 つのモデルを 1 行ずつ整理するとこのようになります。

  • Service + Signal — 小さなアプリ、1〜2 画面が共有するシンプルな状態
  • Component Store / SignalStore — feature 単位の状態、非同期の流れがある程度ある画面
  • NgRx Store + Effects — 複数の feature が同じドメインデータを共有、コラボの規模が大きく、流れの追跡/監査の価値が大きいアプリ
ヒント

「NgRx を使うべきか?」 を素早く見極めるヒューリスティックです。

  1. 同じデータを見る feature が 3 つ以上、画面間のリアルタイム同期が必要だ
  2. 非同期の流れ(ロード/エラー/キャンセル)が 1 つのページに同時に複数表示されている
  3. QA で再現の難しいバグを タイムトラベルデバッグ で追跡する価値がある
  4. チームが 4 人以上で、「状態がどこでどう変わるか」 のコンベンションを強制したい

3 つ以上に該当すれば NgRx Store を導入する価値があります。1〜2 つなら SignalStore がより軽くて速いです。小さなアプリに NgRx を入れることはほぼ常に過剰な選択 です。

まとめ #

今回は NgRx の入門を一気に流しました。

  • 状態の流れが複雑になれば Redux パターン の明示性が価値を作る
  • Single source of truth / immutable / 単方向 の 3 原則
  • Action は何が起きたか、Reducer は新しい state、Selector は読み取り、Effect は副作用をアクションの流れで
  • モダンセットアップは provideStore + provideEffects の standalone パターン
  • Component Store は feature 単位の軽量な store、SignalStore は signal フレンドリーな新モデル
  • 小さなアプリは Service + Signal で十分。NgRx はコラボ・ドメインがしきい値を超えたとき

次回の「Angular上級 #6 SSR とハイドレーション」では、視線をクライアントの外側に向けます。Angular アプリをサーバーで予め描画して送る SSR、そして Angular 17 以降に標準となった non-destructive hydrationincremental hydration まで — 初回ペイントと SEO を同時に取る方法を順を追って見ていきます。

X