Dev Study
← 解説「状態の置き場所 — コンポーネント・サービス・URL」に戻る

サンプルコードで身につける: 状態の置き場所 — コンポーネント・サービス・URL

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1①画面の中だけの状態 — コンポーネントの signal

モーダルの開閉やタブの選択のように、その画面を離れたら消えてよい状態はコンポーネントの signal に置きます。置き場所が最も近いので読みやすく、画面と一緒に破棄されるため掃除も不要です。

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-product-detail',
  template:
    '<button (click)="isOpen.set(true)">レビューを書く</button>' +
    '@if (isOpen()) {' +
    '  <div class="modal">...<button (click)="isOpen.set(false)">閉じる</button></div>' +
    '}',
})
export class ProductDetail {
  // モーダルの開閉は「この画面だけの事情」なのでここに置く。
  // 画面を離れれば状態ごと消えるので、リセット処理も不要
  isOpen = signal(false);
}

2②複数画面で共有する状態 — サービスの signal(ストア)

カートの中身は商品一覧でもヘッダーでも使うので、root提供のサービスに signal で持たせます。書き換えはサービスのメソッド経由に限定し、外へは読み取り専用で公開するのが安全な形です。

import { Injectable, computed, signal } from '@angular/core';
import { Product } from './product.model';

// 複数画面で共有する状態は、サービスの signal に置く(ストア)
@Injectable({ providedIn: 'root' })
export class CartStore {
  // 書き換え可能な signal は private にして勝手な変更を防ぐ
  private readonly _items = signal<Product[]>([]);

  // 外へは読み取り専用で公開する
  readonly items = this._items.asReadonly();
  readonly count = computed(() => this._items().length);

  add(product: Product) {
    this._items.update((items) => [...items, product]);
  }
}

// 商品一覧は add() を呼び、ヘッダーは count() を表示するだけ。
// どの画面から見ても同じ1つのカートになる

3③URLに置くべき状態 — 検索条件はクエリパラメータへ

検索キーワードやページ番号を signal だけに置くと、リロードやURL共有で状態が消えます。URLに載せておけば、ブックマークしても同僚にURLを送っても同じ画面が再現されます。

import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';

// ── Before: 検索条件を signal だけに置く ──
// keyword = signal('');
// → リロードすると消える。URLを共有しても相手には初期画面が出る

// ── After: URLのクエリパラメータを正とする ──
@Component({ selector: 'app-product-search', template: '...' })
export class ProductSearch {
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  // 検索実行 = URLを書き換える(/products?keyword=apple&page=1)
  search(keyword: string) {
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { keyword, page: 1 },
    });
  }
}
// 画面側は queryParams の変化を読んで一覧を取得し直す。
// リロードしてもブックマークしても同じ検索結果が再現される

4アンチパターン: とりあえず全部サービスに置く

1画面限りの状態までサービスに置くと、画面を離れても状態がアプリ全体に残り続けます。再訪問時に前回のモーダルが開いたままになるなど、リセット漏れのバグの温床になります。

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

// ── Before: 画面限りの状態までサービスに置いてしまった ──
@Injectable({ providedIn: 'root' })
export class UiStateService {
  isDetailModalOpen = signal(false); // 詳細画面のモーダル開閉
  selectedTab = signal(0);           // 設定画面のタブ選択
  // → 画面を離れても残り続ける。再訪問時に前回の状態が出てしまい、
  //   画面ごとのリセット処理をあちこちに書く羽目になる
}

// ── After: それぞれの画面のコンポーネントに置く ──
// ProductDetail  →  isOpen = signal(false);
// SettingsPage   →  selectedTab = signal(0);
// 画面の破棄と一緒に状態も消えるので、リセット処理そのものが不要

5アンチパターン: 全部コンポーネント → バケツリレー

共有すべき状態をコンポーネントに置き続けると、深い階層へ Input を延々と受け渡すバケツリレーが始まります。中継しているだけの層が増えたら、サービスへ昇格させる合図です。

// ── Before: カートの中身を App が持ち、Input で配り続ける ──
//
// App(items を signal で保持)
//  └─ <app-layout [items]="items()">        … 使わないのに中継
//      └─ <app-header [items]="items()">     … 使わないのに中継
//          └─ <app-cart-badge [items]="items()">  … やっと使う
//
// 中間の Layout と Header は items を表示しないのに
// Input の定義と受け渡しだけを背負わされている

// ── After: CartStore に昇格し、使う場所だけが inject する ──
//
// App          … カートを知らなくてよくなる
// Layout       … 何も中継しない
// Header       … 何も中継しない
// CartBadge    … private cart = inject(CartStore); で直接読む
//
// 目安: 「自分では使わない Input を中継する層」が2つ出たら昇格を検討