Angular実践 #4 状態管理 — SignalStore で整理する

読了 9分

#3 では、ProductService の中に signal で状態を直接埋め込んで、そこから add/update/delete を呼びながら optimistic update まで載せてみました。画面が 2 つくらいの間はそれでちょうどよく、実際小さなアプリではそれが正解でもあります。

問題はページが増えてくる瞬間から始まります。一覧ページ、詳細ページ、検索・フィルタのサイドバー、アドミンの素早い編集モーダルまで — 全員が同じ products データを別々の形で見せようとすると、Service という 1 クラスにシグナルとメソッドがじわじわ積み上がり、「この値を誰がいつどう変えるんだっけ?」が曖昧になり始めます。今回はそれを @ngrx/signals の SignalStore できれいに整理します。

NgRx vs SignalStore — 本アプリにはどちら? #

上級 #5 NgRx 入門 で押さえましたが、もう一度整理します。@ngrx/store + @ngrx/effects のクラシックな NgRx は、複数の feature が同じドメインデータを共有し、共同作業の人数が多く、タイムトラベルデバッグの価値が大きいアプリ のためのツールです。アクション・リデューサー・セレクター・エフェクトという明示的な単方向フローの代償として、ボイラープレートが付いてきます。

今扱っている商品管理アプリは、その閾値をはるかに下回ります。1 ドメイン (products)、2~3 つの画面、1 人から 2~3 人で触る規模。この規模に合うのが SignalStore です。シグナルを第一級として扱い、アクション/リデューサーを別途定義せず、状態・派生値・メソッドを 1 つの場所に まとめます。小さなアプリには軽く、それでいて素の Service よりは整っている — その中間に位置します。

@ngrx/signals のセットアップ #

別途 provider 登録は不要です。signalStore{ providedIn: 'root' } だけ書いておけば、依存性注入ツリーに自動で組み込まれます。パッケージをインストールすれば終わりです。

ターミナル
ng add @ngrx/signals@latest

ng add がうまくいかなければ npm i @ngrx/signals でも十分です。他の NgRx パッケージ (@ngrx/store@ngrx/effects) は 必要ありません。SignalStore はクラシック NgRx とは別に単独で動きます。

signalStore 4 兄弟 — withState、withComputed、withMethods、withHooks #

本格的なコードに入る前に、絵を 1 枚だけ頭に描いておきます。SignalStore は signalStore(...features) の形で作られ、feature は次の 4 つでほぼ全部です。

  • withState(initial) — 初期状態。中のフィールドはすべて自動でシグナルになります
  • withComputed(({ ...state }) => ({ ... })) — 派生値。シグナルの computed と同じ発想
  • withMethods((store, ...deps) => ({ ... })) — 状態を変える動作。inject() で依存を取得可能
  • withHooks({ onInit, onDestroy }) — ライフサイクル。永続化のような自動同期に使う

withState で敷いたフィールドは その場でシグナルになりますstate.items ではなく store.items() のように関数呼び出しで読み、変更は patchState(store, { items: ... }) で行います。直接 set を呼ぶことはありません。

Product Store の実装 #

#3ProductService に散らばっていた状態とメソッドを移してみます。まずはドメイン型と初期状態から。

src/app/products/product.model.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  stock: number;
}

export interface ProductsState {
  list: Product[];
  selectedId: number | null;
  loading: boolean;
  error: string | null;
}

export const initialProductsState: ProductsState = {
  list: [],
  selectedId: null,
  loading: false,
  error: null,
};

そして store 本体です。1 つのファイルにすべてが収まります。

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

import { ProductsApi } from './products.api';
import { Product, initialProductsState } from './product.model';

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  withState(initialProductsState),

  withComputed(({ list, selectedId }) => ({
    count: computed(() => list().length),
    selected: computed(() =>
      list().find((p) => p.id === selectedId()) ?? null,
    ),
    outOfStock: computed(() => list().filter((p) => p.stock === 0)),
  })),

  withMethods((store, api = inject(ProductsApi)) => ({
    async load() {
      patchState(store, { loading: true, error: null });
      try {
        const list = await api.fetchAll();
        patchState(store, { list, loading: false });
      } catch (err: any) {
        patchState(store, { error: err.message ?? '読み込み失敗', loading: false });
      }
    },

    select(id: number | null) {
      patchState(store, { selectedId: id });
    },

    async add(input: Omit<Product, 'id'>) {
      const tempId = -Date.now();
      const optimistic: Product = { ...input, id: tempId };
      patchState(store, ({ list }) => ({ list: [...list, optimistic] }));

      try {
        const created = await api.create(input);
        patchState(store, ({ list }) => ({
          list: list.map((p) => (p.id === tempId ? created : p)),
        }));
      } catch (err: any) {
        patchState(store, ({ list }) => ({
          list: list.filter((p) => p.id !== tempId),
          error: err.message ?? '追加失敗',
        }));
      }
    },

    async update(id: number, patch: Partial<Product>) {
      const prev = store.list().find((p) => p.id === id);
      if (!prev) return;

      patchState(store, ({ list }) => ({
        list: list.map((p) => (p.id === id ? { ...p, ...patch } : p)),
      }));

      try {
        await api.update(id, patch);
      } catch (err: any) {
        patchState(store, ({ list }) => ({
          list: list.map((p) => (p.id === id ? prev : p)),
          error: err.message ?? '更新失敗',
        }));
      }
    },

    async remove(id: number) {
      const prev = store.list();
      patchState(store, { list: prev.filter((p) => p.id !== id) });

      try {
        await api.remove(id);
      } catch (err: any) {
        patchState(store, { list: prev, error: err.message ?? '削除失敗' });
      }
    },
  })),
);

流れを一度なぞって読んでみると — 状態の形は withState に書いてあり、画面でよく問い合わせられる派生値は withComputed に集まっており、実際の動作は withMethods にあります。3 つの領域が明確に分かれている わけです。

withMethods の第 2 引数で inject(ProductsApi) で API クライアントを取り込んでいます。クラシック NgRx の Effects クラスで見た inject パターンがそのまま入ってきています。メソッド内では store.list() のように現在の状態を読むこともできpatchState で更新もできます。

注記
patchState は 2 つの形を両方受け付けます — オブジェクトリテラル patchState(store, { loading: true }) で丸ごと上書きする方法と、コールバック patchState(store, ({ list }) => ({ list: [...list, x] })) で前の状態から派生する方法。配列・オブジェクトのように前の値を参照して新しい値を作るときは、コールバック形式が安全です。

コンポーネントでの利用 #

これで画面側がぐっと軽くなります。inject(ProductsStore) で store を受け取れば、中のシグナルをそのままテンプレートで呼べば OK です。

src/app/products/products-page.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { ProductsStore } from './products.store';

@Component({
  selector: 'app-products-page',
  standalone: true,
  template: `
    @if (store.loading()) {
      <p>読み込み中…</p>
    } @else if (store.error()) {
      <p class="error">{{ store.error() }}</p>
    } @else {
      <p>合計 {{ store.count() }} 件 (品切れ {{ store.outOfStock().length }} 件)</p>

      @for (p of store.list(); track p.id) {
        <article (click)="store.select(p.id)">
          <h3>{{ p.name }}</h3>
          <p>{{ p.price | number }}円 · 在庫 {{ p.stock }}</p>
          <button (click)="store.remove(p.id); $event.stopPropagation()">削除</button>
        </article>
      }

      @if (store.selected(); as sel) {
        <aside>選択中: {{ sel.name }}</aside>
      }
    }
  `,
})
export class ProductsPageComponent implements OnInit {
  protected readonly store = inject(ProductsStore);

  ngOnInit() {
    this.store.load();
  }
}

テンプレートでは store.list()store.count()store.selected() のように単にシグナルとして呼びます。async パイプも、Subscription の片付けもありません。OnPush 変更検知と自然に噛み合って、シグナルが指し示す部分だけを正確に再描画します。

Optimistic update の置き場所を移す #

#3 では ProductService に置いていた optimistic update のロジックが、今回は store の withMethods に引っ越してきました。些細に見えますが大切な変化です — 副作用を起こす役割が store の責任になることで、コンポーネントは「何をしてほしい」を呼び出すだけ になります。コンポーネントに散らばっていた try/catch や一時 id 管理が 1 カ所にまとまります。

update/remove も同じパターンです — 直前の状態 (prev) を変数に握っておき、楽観的に反映し、サーバーに拒否されたらその変数で戻します。コンポーネントは store.update(id, patch) とだけ書きます。どの画面が呼んでも同じ保証を受けます。

Filter / Sort — withComputed できれいに #

一覧を検索語で絞って、価格順に並べ替える流れはシグナル環境にぴったり当てはまります。UI の検索語を store に追加し、絞り込んだ結果を withComputed で派生させればよいです。

src/app/products/products.store.ts (filter 追加)
withState({
  ...initialProductsState,
  query: '',
  sortBy: 'name' as 'name' | 'price',
}),

withComputed(({ list, query, sortBy }) => ({
  filtered: computed(() => {
    const q = query().trim().toLowerCase();
    const base = q
      ? list().filter((p) => p.name.toLowerCase().includes(q))
      : list();
    return [...base].sort((a, b) =>
      sortBy() === 'price' ? a.price - b.price : a.name.localeCompare(b.name),
    );
  }),
})),

withMethods((store) => ({
  setQuery(q: string) { patchState(store, { query: q }); },
  setSortBy(s: 'name' | 'price') { patchState(store, { sortBy: s }); },
  // ... 既存メソッドたち ...
})),

画面では検索入力の (input) イベントを store.setQuery($event.target.value) で受け、テンプレートでは store.list() の代わりに store.filtered() を描けば終わりです。

検索入力の binding
<input
  type="search"
  [value]="store.query()"
  (input)="store.setQuery($any($event.target).value)"
  placeholder="商品名で検索"
/>

<select [value]="store.sortBy()" (change)="store.setSortBy($any($event.target).value)">
  <option value="name">名前順</option>
  <option value="price">価格順</option>
</select>

filteredlist/query/sortBy のうちどれかが変わったときだけ 再計算されます。別のところで selectedId が変わっても、filtered はキャッシュされた値をそのまま返します。これがシグナルベースの派生値の最大の利点です。

永続化 — withHooks で sessionStorage に自動保存 #

選択中の商品 id をリロードしても保持したい場合、withHooksonInit でシグナルの変化を聴いて、sessionStorage に同期させればよいです。effect を store の中でそのまま使えます。

src/app/products/products.store.ts (永続化を追加)
import { effect } from '@angular/core';
import { signalStore, withHooks } from '@ngrx/signals';

const SELECTED_KEY = 'products.selectedId';

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  // ... withState、withComputed、withMethods ...

  withHooks({
    onInit(store) {
      // 1) 起動時点に一度だけ復元
      const saved = sessionStorage.getItem(SELECTED_KEY);
      if (saved) patchState(store, { selectedId: Number(saved) });

      // 2) 以降の変化は effect で自動同期
      effect(() => {
        const id = store.selectedId();
        if (id == null) sessionStorage.removeItem(SELECTED_KEY);
        else sessionStorage.setItem(SELECTED_KEY, String(id));
      });
    },
  }),
);

withHooks のコールバックは コンポーネントではなく store の注入コンテキスト で実行されます。そのため effect がその場で自然に登録され、store が生きている間 — providedIn: 'root' ならアプリの寿命の間ずっと — 自動で同期されます。unsubscribe や cleanup を別途書くことはありません。

同じパターンで querysortBy もまとめておけば、検索状態までリロードに耐えるようになります。ただしすべてのフィールドを無闇に永続化するとデバッグが難しくなるので、ユーザーの意図が込められた入力値 (検索語、選択 id、並び替え基準) だけ選んで保存するほうがきれいです。

DevTools — NgRx Store DevTools との互換性 #

@ngrx/signals でも Redux DevTools 拡張をそのまま活用できます。@ngrx/signals/devtools が提供する withDevtools('products') を store に 1 行追加するだけで、ブラウザの Redux DevTools パネルから store の状態変化を時系列で覗けるようになります。

DevTools との接続
import { withDevtools } from '@ngrx/signals/devtools';

export const ProductsStore = signalStore(
  { providedIn: 'root' },
  withDevtools('products'),
  withState(initialProductsState),
  // ...
);

ただしクラシック NgRx の アクション単位のタイムトラベル とは趣が異なります。SignalStore はアクションが別途ないため、「どのメソッド呼び出しがどの変化を起こしたか」がアクション名のようにそのままラベルとして刻まれます。小さなアプリでは十分で、それ以上にフロー追跡が必要になったらクラシック NgRx に移していく流れが自然です。

ヒント
SignalStore を初めて導入するときに最も多い失敗は、store の中から別の store を直接 mutate すること です。ある store の withMethods から別の store のメソッドを呼ぶのは問題ありませんが、別の store のシグナルを patchState で直接書き換えてはいけません。store 同士の結合は メソッド呼び出し だけで — 1 つの store が自分の状態の単一責任者です。

まとめ #

今回は #3ProductService に埋まっていた状態と動作を、SignalStore に移して整理しました。

  • 本アプリの規模には クラシック NgRx より SignalStore のほうが適切。小さなアプリでもボイラープレートなしで整った store
  • withState / withComputed / withMethods / withHooks の 4 兄弟で、状態・派生値・動作・ライフサイクルが 1 ファイルに
  • コンポーネントは inject(ProductsStore) で受け取り、シグナルとして直接呼ぶ。async パイプ不要
  • Optimistic update の置き場が service から store に移って、コンポーネントが軽くなる
  • 検索・並び替えは withComputed で自動メモ化。依存するシグナルが変わったときだけ再計算
  • withHooks + effect で sessionStorage の永続化。cleanup は自動
  • Redux DevTools と互換性あり。大きなアプリに育ったらクラシック NgRx に移る道が開かれている

次回 「Angular実践 #5 チャートとデータテーブル」 では、ここまで整えてきた store の上に本格的な可視化を載せます。ng2-charts で売上チャートを、CDK Table で並び替え・ページネーションが付いたデータテーブルを作り、store の withComputed でチャートのデータまできれいに派生させる流れを追っていきます。

X