🌏

【状態管理ライブラリ不使用】 Angularの状態管理パターンまとめ

2024/05/29に公開
  • Angularの状態管理について社内用にまとめる機会があり、せっかくなのでここで供養
  • 前提として、外部の状態管理ライブラリ(例:NgRx)を使用していないため、素のAngularで実現可能なパターンを列挙している

状態管理とは?

  • ここでいう「状態」とは、コンポーネントレベルの「ローカルな状態」からアプリケーション全体の「グローバルな状態」までをまとめて指す用語とする
  • アプリケーションの状態は、UIに表示されるデータや、ユーザーのインタラクションによって変化するデータである
    • 例: クリックで+1するカウンターの値、ユーザーが入力したフォームの値、APIから取得したデータ等
  • これらのデータを必要に応じて取得したり更新したりすることを「状態管理」と呼ぶ

状態管理のパターン分類

  • 外部の状態管理ライブラリを使用せず、ブラウザ・AngularおよびRxJSの機能を用いて状態管理を行っている

パターン分類と階層図

  • (結論)後述のパターンをまとめると以下の図のような階層構造になる
    状態管理パターン階層図

Componentパターン

概要

  • 一番基本的な方法。AngularにおいてComponentはclassであるため、classのプロパティとして状態を保持するパターン
  • 保持する状態はコンポーネントのライフサイクルに従う。コンポーネントが破棄(例:そのコンポーネントを呼び出しているページから別のページに遷移する)されれば、その状態も破棄される
  • バックエンドから取得したデータは、Serviceを通じてComponentのプロパティに保持するケースが大半
Componentパターンの例

@Component({
  standalone: true,
  // 中略
})
export class MenuComponent {
  private readonly _menuService = inject(MenuService);

  // 親から受け取る状態
  @Input()
  enabled = true;

  // バックエンドから取得したデータを保持し、画面に表示
  readonly menus = toSignal(this._menuService.list());

  // 選択されたメニューを保持 ※下の例のようにsignalの方が好ましい
  selectedMenu?: Menu;

  // 本日のおススメニューを保持
  readonly todayRecommendedMenu = signal<Menu>({ name: '初ガツオ藁焼きたたき' });

  // ...
}

Pros/Cons

  • Pros
    • 手っ取り早い
    • Component(コンポーネント階層があったとしてもその階層内)に閉じているため、状態のフローを追いやすい
  • Cons
    • アプリケーション内のページやブラウザのタブ・ウィンドウにまたがった状態の保持はできない
    • プロパティが乱立するとコンポーネントの複雑性が加速度的に増える
      • その際は適切なコンポーネントの分割、他パターンの採用を検討すること

使用例

  • 選択対象を保持する
  • フォームの入力値をリクエストが送られるまで保持する
  • ダイアログを開く際にデータを渡す
  • Inputで親から値(状態)を受け取る

Serviceパターン

概要

  • Serviceクラスのプロパティに状態を保持するパターン
  • AngularのServiceは主に以下の3パターンに区分できる
    • アプリケーション全体で共有するシングルトンのGlobal Service
      Global Serviceの例
      
      export type FilterState = Readonly<{
        name: string,
        date: DateTime,
        // ...
      }>
      
      @Injectable({
        // root設定によりアプリケーション全体で提供されるシングルトンインスタンスになる
        providedIn: "root"
      })
      export class FilterStoreService {
        private readonly _filter$ = new BehaviorSubject<FilterState | undefined>(undefined);
      
        readonly filter$ = this._filter$.asObservable();
      
        store(filter: FilterState): void {
          this._filter$.next(filter);
        }
      
        reset(): void {
          this._filter$.next(undefined);
        }
      }
      
    • Module/Routes単位でインスタンス生成するModule Local Service
      Module Local Serviceの例
      
      // 設定値を省略した場合以下と同じ
      // @Injectable({ providedIn: "any" })
      @Injectable()
      export class ModuleFilterStoreService {
        private readonly _filter$ = new BehaviorSubject<FilterState | undefined>(undefined);
        // 以下GlobalServiceと同様
      }
      
      // Routesでの設定例 xxx-routes.ts
      export const XXXPageRoutes: Routes = [
        {
          path: '',
          providers: [
            // この設定により、このRoutes内でModuleFilterStoreServiceのインスタンスが共有される
            ModuleFilterStoreService,
          ],
          children: [
            {
              path: '',
              component: XXXTopComponent,
            },
            // ...
          ],
        },
      ];
      
      // Moduleでの設定例 xxx.module.ts
      // ※NgModuleよりも基本はStandaloneComponentで解決を
      @NgModule({
        imports: [
          XXXTopComponent,
          XXXListComponent,
          // ...
        ],
        providers: [
          ModuleFilterStoreService,
        ],
      })
      export class XXXModule {}
      
    • Component単位でインスタンス生成するComponent Local Service
      Component Local Serviceの例
      
      // component-state-store.service.ts
      export type ComponentState = Readonly<{
        mode: ComponentMode,
        date: DateTime,
        // ...
      }>
      
      @Injectable()
      export class ComponentStateStoreService {
        private readonly _state$ = new BehaviorSubject<ComponentState | undefined>(undefined);
        // 以下GlobalServiceと同様
      }
      
      // Componentでの設定例 xxx-parent.component.ts
      @Component({
        selector: 'app-xxx-parent',
        template: `
          <app-xxx-child></app-xxx-child>
        `,
        providers: [
          // この設定により、このComponent階層以下でComponentStateStoreServiceのインスタンスが共有される
          ComponentStateStoreService,
        ],
      })
      export class XXXParentComponent {
        private readonly _stateService = inject(ComponentStateStoreService);
      
        // Observableで扱うパターン
        readonly state$ = this._stateService.state$;
        // またはSignalで扱うパターン
        readonly state = toSignal(this._stateService.state$);
      
        // ...
      }
      
  • いずれもインスタンス生成タイミングは、表示対象のComponent等が読み込まれたタイミングである
    • Local ServiceはComponent等で明示的にProviders設定を書く必要がある
    • Global Serviceは注入か所が初めて読み込まれたタイミングで自動的にインスタンス生成される

種類ごとのPros/Cons

  • Serviceパターン共通
    • Pros
      • 元のデータ型をそのまま保持できる(例:LuxonのDateTime、関数、クラス)
    • Cons
      • 状態がComponentから離れるため、そのフローが複雑になる可能性がある(例:Componentの親子間の両方で状態を更新する等)
      • アプリケーションが終了する(ブラウザ再読み込み、タブ・ウィンドウ閉じる)と状態が失われる
  • Global Service
    • Pros
      • インスタンス生成~アプリケーション終了まで生存するため、アプリケーション全体で状態を共有でき、画面遷移しても状態が保持される
    • Cons
      • 共通のConsと同じ
  • Module Local Service
    • Pros
      • Module/RoutesにProvider設定することによりそれらの中でインスタンスが共有されるため、その範囲内であれば画面遷移しても状態が保持される
      • Module/Routes内に状態の範囲を限定できる
    • Cons
      • 共通のConsと同じ
  • Component Local Service
    • Pros
      • そのComponentおよび呼び出しているComponent内でのみインスタンスが共有されるため、Component階層内に状態の範囲を限定できる
    • Cons
      • Componentのライフサイクルに従うため、Componentが破棄されると状態も破棄される(画面遷移等に対応しない)

(補足)Global Serviceか?Local Serviceか?

  • 基本的にGlobal Serviceでよい
  • Module Local Serviceは複数インスタンスが必要な場合にのみ採用すべき
    • 例:その箇所ごとに状態のあるページネーターの状態保持
    • 状態にアクセスできる範囲を限定できるのはメリットだが、Global Serviceでも不必要に注入しなければ問題は回避できる
    • もっというと、そのModule/Routes内で複数インスタンス必要ならComponent Local Serviceを採用できる

Web Storageパターン

概要

  • ブラウザのStorageに状態を保持するパターン
  • プロダクトではWeb Storage APIのLocalStorageとSessionStorageを使用していた
    • LocalStorage:ブラウザ(タブ・ウィンドウ)を閉じてもデータが保持される
    • SessionStorage:ブラウザを閉じるとデータが消える
  • 両者ともに文字列のみ保存可能
  • 同期的に処理される
  • 他にブラウザで使用できるStorageはこちらの記事を参照:ブラウザのデータストレージについてまとめ
  • 後述のConsにも書くが、Web Storage APIはアプリケーションコードのどこからでもアクセスできてしまうため、使用方法を統一・制限する目的から、ラップしたクラス等を用意するとよい
    • 下記の例では実装の差し替えを容易にするため、クラスWebStorageApiをServiceにDIする形を採用している
    • そこまで考えないなら、WebStorageApiWebStorageServiceとしてコンポーネントから直接呼び出してもいいかもしれない
Web Storageパターンの例

export type Authentication = {
  key: 'AUTHENTICATION',
  id: string,
  name: string,
}

export type Calendar = {
  key: 'CALENDAR',
  mode: string,
  daysOfWeek: string[],
}

export type StorageKeyName = Authentication['key'] | Calendar['key']

type _StorageData = Authentication | Calendar
export type StorageData<K extends StorageKeyName> = Extract<_StorageData, { key: K }>

@Injectable({ providedIn: 'root' })
export class WebStorageApi {
  storeLocalStorage(key: StorageKeyName, value: unknown): void {
    window.localStorage.setItem(key, JSON.stringify(value));
  }

  lookupLocalStorage<T extends StorageKeyName>(key: T): StorageData<T> | null {
    const info = window.localStorage.getItem(key);
    return info == null ? null : JSON.parse(info);
  }

  deleteLocalStorage(key: StorageKeyName): void {
    window.localStorage.removeItem(key);
  }

  // SessionStorageも同様
}

// WebStorageApiを利用するServiceの例
@Injectable({providedIn: 'root'})
export class AuthenticationStoreService {
  private readonly _storage = inject(WebStorageApi);

  get(): Authentication | null {
    return this._storage.lookupLocalStorage<Authentication>('AUTHENTICATION');
  }

  store(value: Authentication): void {
    this._storage.storeLocalStorage('AUTHENTICATION', value);
  }

  delete(): void {
    this._storage.deleteLocalStorage('AUTHENTICATION');
  }
}

Pros/Cons

  • Pros
    • ブラウザリロードしてもデータが保持される
    • 画面遷移してもデータが保持される
    • (LocalStorageのみ)ブラウザを閉じてもデータが保持される
  • Cons
    • 保存できるのは文字列のみのため、クラスや関数等のデータ型をそのまま保存できない
      • それらの型や独自の型を扱いたい場合、シリアライズ/デシリアライズ処理を書く必要がある
    • Web Storage APIはどこからでも取得・上書き・削除等できてしまうため、運用に一定のルールが必要
      • 上記例のとおり、Web StorageへのアクセスはクラスWebStorageApiに限定した
    • (SessionStorageのみ)リンクを右クリックの「新しいタブ(or ウィンドウ)で開く」で開いた場合、データが破棄される
      • 認証情報をSessionStorageに保存すると、別タブで開いたときにログインが画面に戻ってしまう

(補足1)クラスWebStorageApiが返すデータの型付けについて

  • LocalStorageとSessionStorageは文字列しか保存できないため、構造のあるデータを扱うにはJSON.stringifyするしかなく、そのままだとStorageから返す(復元する)データの型はunknown(もしくはany)にせざるを得ない
  • 上記例ではこの問題を解消すべく、ジェネリクスと代数的データ型を用いて、保存に使用できるkeyと復元するデータに制約をかけてみた(もっとよい方法があれば教えてください)

(補足2)LocalStorageか?SessionStorageか?

  • 基本的にLocalStorageでよい
  • SessionStorageを使うのはセキュリティやその他特殊な要件がある場合

Route Parameterパターン

概要

  • 画面遷移時にURLにパラメータを付与し、そのパラメータを遷移後の画面で受け取るパターン
  • 主なパラメータは以下の3種類
    • リソースを特定するためのIDを付与するパスパラメータ(例:/user/detail/:id)
    • リソースの属性を指定するために付与するマトリクスパラメータ(例:/ramen;soup=salt;amount=large)
    • その他リソースに関連しないデータ(検索条件等)を付与するクエリパラメータ(例:/car/search?color=rainbow)
Route Parameterパターンの例

// パラメータを受け取る側の例
@Component({
  // 省略
})
export class XXXDetailPageComponent {
  private readonly _route = inject(ActivatedRoute);

  // Angular v16からパスパラメータはInputで受け取れるようになった
  @Input()
  id!: string;

  // マトリクス or クエリパラメータの取得
  readonly type = this._route.params.pipe(
    map(params => {
      // パラメータの有無をチェック
      if (params.type == null) {
        // 別の画面に遷移させる等
        return;
      };
      return params.type;
    })
  );
}

Pros/Cons

  • Pros
    • URL直アクセスに対応できる
    • ブラウザリロードしてもデータが保持される
  • Cons
    • URLの正当性を検査する必要がある
    • パラメータは文字列であるため、クラスや関数等のデータ型をそのまま保持できない
    • 保持したいデータが多いとURLが長くなる

状態管理パターン採用判断簡易フローチャート

  • 各パターン採用の判断基準として、以下にフローチャートをまとめた
  • あくまで簡易のフローとして、上述の各Pros/Consや補足等を踏まえた上で判断すること

状態管理パターン採用基準簡易フローチャート

Discussion