🐟

[Angular][状態管理]NgRxの基本

2022/07/20に公開

はじめに

NgRxを用いて状態管理を行うことになったので、学習ということでまとめてみました。
NgRxは、Reduxなど他の状態管理ライブラリに対して情報が少ないうえに、RxJSやFlux(アーキテクチャ設計思想)などの前提知識も多いので学習コストは高いと思います。
私も全体のうちほんの一部しか理解できていませんが、公式ドキュメントや参考にさせて頂いた情報を元に、自分が理解できるように書き出しました。

この記事で紹介するのは下記の通りです。

  • @ngrx/store
    • Action
    • Store
    • Reducer
    • Selector
  • @ngrx/Effect

なお、@ngrx/Entityは割愛します。

NgRxとは

Angular用の状態管理ライブラリです。

NgRx Docs

Reduxというライブラリに影響を受けて作成されたAngular専用の公式ライブラリです。
NgRxを利用するとReduxのような状態管理を実現できます。

💡 Fluxの思想に従い、すべての状態(State)を一か所のStoreで一元管理し、Component間でのStateを扱いやすくします。

また、NgRxはRxJSベースで作られており、状態をストリームとして扱うことができます。
Angularのデフォルト機能だけを使ってもアプリケーションの状態管理はできますが、NgRxをあえて使う理由としては、コードが自動生成できること、デバック用のツールが付属していることが挙げられます。

NgRxの概念図

  1. Viewからイベントが発火
  2. ActionをReducerへdispatch(通知)
  3. Reducerが受け取ったActionのタイプによって処理を行い、Stateを更新
  4. Selectorを使う場合は、SelectorでStateを取得し、Componentへ

主な構成要素

  • Action
  • Reducer
  • Store
  • Effect

@ngrx/store

@ngrx/storeはNgRxの機能の中枢となる機能です。

NgRx/Storeを状態管理に使用する理由は?

NgRx Storeは、状態の変更を表現するために単一の状態とアクションを使用することにより、保守可能で明示的なアプリケーションを作成するための状態管理を提供します。

状態管理にNgRx/Storeを使用するのはどのような場合?

特に、多くのユーザーインタラクションや複数のデータソースを持つアプリケーションを構築するとき、またはサービスでの状態管理がもはや十分でないときにNgRxを使うのをおすすめします。

💡 しかし、NgRx Storeを利用することはいくつかのトレードオフを伴うことを理解することも重要です。NgRx Storeは、コードを書くための最短・最速の方法というわけではありません。

💡 NgRx Storeで実装されているパターンを考慮することも重要です。NgRx Storeや他の状態管理ライブラリの使い方を学ぶ前に、RxJSとReduxについてしっかり理解しておく必要があります。

コンセプト

型安全性
NgRxでは、プログラムの正確性をTypeScriptコンパイラに依存することでアーキテクチャ全体を通じて型安全性を推奨しています。
また、NgRxの型安全性の厳しさとパターンの利用は、より高品質なコードの作成に適しています。

不変性とパフォーマンス
Storeは単一の不変データ構造で構築されており、OnPush戦略を使用した変更検出が比較的簡単なタスクになります。NgRx Storeはまた、状態からデータを取得することを最適化するメモ化されたセレクタ関数を作成するためのAPIを提供します。

カプセル化
NgRx Effects とStoreを使用すると、ネットワークリクエストやウェブソケットなどから外部リソースのサイトエフェクトやビジネスロジックとのやり取りをUIから分離することができます。
この分離により、より純粋でシンプルなコンポーネントと単一責任原則を維持することができます。

シリアライザビリティ
NgRxは状態の変化を正規化し、observableを通して渡すことで、シリアライザブルを提供し、状態が予測可能に保存されることを保証します。これにより、状態をlocalStrageのような外部ストレージに保存することができます。
また、Store Devtoolsから検査、ダウンロード、アップロード、アクションのディスパッチが可能です。

テスト可能
Storeは、ステートからデータの変更と選択に純粋な関数を使用し、UIから副作用を分離することができるため、テストが非常に簡単になります。NgRxはまた、分離されたテストと全体的に優れたテスト体験のために、provideMockStoreとprovideMockActionsなどのテストリソースを提供します。

と、まあ難しいことが公式ドキュメントには書かれていますが、細かいことは追々理解すればよいでしょう。

Action

Storeに保持するすべてのStateは、変更を加えるときは直接操作することはできません。
必ずActionを発行後、Reducerにdispacthし、Reducerからのみ操作できます。
Actionのインターフェースはシンプルです。
Actionはアプリケーション全体で発生する固有のイベントを識別できるように管理しています。

例)

login-page.actions.ts

import { createAction, props } from '@ngrx/store';

export const login = createAction(
  '[Login Page] Login',
  props<{ username: string; password: string }>()
);
  • createAction関数を使ってActionメソッドを定義します。
  • 第一引数はtype、つまりそのActionの名前を指定します。’[カテゴリ名] イベント名’にするとわかりやすいです。
  • 第二引数はprops関数で、Actionの処理に必要な追加のメタデータを定義できます。

定義したActionを使うにはReducerにdispatchします。
例えば、ViewでクリックイベントでonSubmit()メソッドを実行したときに、dispatchしたい場合は下記のようにします。

なお、使いたいActionはあらかじめ任意のコンポーネントでインポートしてください。
また、Storeモジュールもインポート必要です。

login-page.component.ts

import { Store } from '@ngrx/store';
import * as LoginActions from 'src/app/actions/login.action';

constructor(private store: Store) {}

onSubmit(username: string, password: string) {
  store.dispatch(LoginActions.login({ username: username, password: password }));
}
  • store.dispatch(アクション名());
    とすることでReducerにdispatchすることができます。
  • loginという名前のActionにusernamepasswordのパラメータを渡します。
  • Actionではusernamepasswordのプロパティと’[Login Page] Login’というType名を含んだプレーンなオブジェクトを返します。
  • 返されたオブジェクトdispatchすることでReducerに渡されます。

Reducer

これまでに何度も登場してましたが、ReducerはActionを受け取って(dispatchされて)状態を次の状態へと変化させる処理を持ちます。
受け取ったActionのタイプによってどのActionの処理をするか決めます。

Reducerは純粋な関数で、入力に対して常に同じ出力を返すだけです。つまり、副作用がなく、dispatchされた最新のActionと現在のStateを受け取り、処理をしてStateを更新します。

Reducerの作成

例としてReducerを作成してみます。

scoreboard-page.actions.ts

import { createAction, props } from '@ngrx/store';

export const homeScore = createAction('[Scoreboard Page] Home Score');
export const awayScore = createAction('[Scoreboard Page] Away Score');
export const resetScore = createAction('[Scoreboard Page] Score Reset');
export const setScores = createAction('[Scoreboard Page] Set Scores', props<{game: Game}>());

次にReducerを定義

scoreboard.reducer.ts

import { Action, createReducer, on } from '@ngrx/store';
import * as ScoreboardPageActions from '../actions/scoreboard-page.actions';

export interface State {
  home: number;
  away: number;
}

export const initialState: State = {
  home: 0,
  away: 0,
};

export const scoreboardReducer = createReducer(
  initialState,
  on(ScoreboardPageActions.homeScore, state => ({ ...state, home: state.home + 1 })),
  on(ScoreboardPageActions.awayScore, state => ({ ...state, away: state.away + 1 })),
  on(ScoreboardPageActions.resetScore, state => ({ home: 0, away: 0 })),
  on(ScoreboardPageActions.setScores, (state, { game }) => ({ home: game.home, away: game.away }))
);
  • ActionがdispatchされるとActionを受け取り、on関数により、どのActionを処理するか決定します。
  • まず、インターフェースとして管理したいStateのプロパティを定義し、初期値としてinitialStateを定義します。
    アプリケーションが読み込まれたときに、このinitialStateがStoreに保持されます。
  • createReducer関数を使ってStateを管理するためのActionを処理するReducer関数を作成します。
  • 上記の場合on関数で4つのActionを設定しています。
    Reducerは各ActionをStateを不変に処理する、つまり、副作用なしで、新しいStateを作成して返すだけです。
  • on関数の引数stateにはStoreに格納されているinitialStateが入ります。
  • stateはState型(オブジェクト型)で受け取っているのでオブジェクト型で処理します。

ReducerとStoreをルートStateとしてアプリケーションに登録

作成したReducerをアプリケーションで使えるようにするには、AppModuleに登録します。

app.module.ts

import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { scoreboardReducer } from './reducers/scoreboard.reducer';

@NgModule({
  imports: [
    StoreModule.forRoot({ game: scoreboardReducer })
  ],
})
export class AppModule {}
  • StoreModule.forRoot({ key名: Reducer名 })でStoreとReducerをルートStateとしてアプリケーション全体に登録します。
  • StoreModule.forRoot()ではキーと値でReducerを登録して識別できるようにします。

💡 このStoreModule.forRoot()で指定したReducerはアプリケーションがリロードされるたびに、読み込まれます。
つまり、この場合リロードするたびにscoreboardReducerの状態は初期状態であるinitialState({home: 0,away: 0,};)になります。

※フィーチャーStateとして登録するには

ルートStateではなく、機能別のフィーチャーStateとして登録するには、Reducerに追加のキーと値を設定します。

scoreboard.reducer.ts

export const scoreboardFeatureKey = 'game';

scoreboard.module.ts

import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { scoreboardFeatureKey, scoreboardReducer } from './reducers/scoreboard.reducer';
  
@NgModule({
  imports: [
    StoreModule.forFeature(scoreboardFeatureKey, scoreboardReducer)
  ],
})
export class ScoreboardModule {}

app.module.ts

import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { ScoreboardModule } from './scoreboard/scoreboard.module';

@NgModule({
  imports: [
    StoreModule.forRoot({}),
    ScoreboardModule
  ],
})
export class AppModule {}

また、他にもActionReducerMapを使ってReducerをまとめる方法もあります。それはまた、NgRxチュートリアルの記事で使ってみます。

なお、Storeの扱いについてもチュートリアルで解説します。

Selector

SelectorはStoreのStateの一部を取得するために使用されます。
createSelector関数を使ってセレクト関数を作成し、Selector経由でStoreから任意のStateを取り出します。
先程の例だと、State = { home: 0, away: 0, };という状態の中から、homeの値のみを取り出すといった感じです。

一つのStateに対してSelectorを使用する場合

index.ts

import { createSelector } from '@ngrx/store';

export interface FeatureState {
  counter: number;
}

export interface AppState {
  feature: FeatureState;
}

export const selectFeature = (state: AppState) => state.feature;

export const selectFeatureCount = createSelector(
  selectFeature,
  (state: FeatureState) => state.counter
);

複数のStateに対してSelectorを使用する場合

index.ts

import { createSelector } from '@ngrx/store';

export interface User {
  id: number;
  name: string;
}

export interface Book {
  id: number;
  userId: number;
  name: string;
}

export interface AppState {
  selectedUser: User;
  allBooks: Book[];
}

export const selectUser = (state: AppState) => state.selectedUser;
export const selectAllBooks = (state: AppState) => state.allBooks;

export const selectVisibleBooks = createSelector(
  selectUser,
  selectAllBooks,
  (selectedUser: User, allBooks: Book[]) => {
    if (selectedUser && allBooks) {
      return allBooks.filter((book: Book) => book.userId === selectedUser.id);
    } else {
      return allBooks;
    }
  }
);
  • createSelector関数は同じStateの中から、一部のデータを選択するために使用します。
  • 例えばStateの中に、selectedUserオブジェクトと、allBookオブジェクトがあるとします。
    createSelectorを使うと「現在のユーザのすべての書籍」をフィルタリングして取得することができます。

★フィーチャーStateをSelectする場合

createFeatureSelector関数を利用すると、どのフィーチャーのStateを扱うか指定できます。
アプリケーションの規模が大きくなるとこのパターンの方が多いかも。

index.ts

import { createSelector, createFeatureSelector } from '@ngrx/store';

// フィーチャーStateを識別するキー
export const featureKey = 'feature';

// フィーチャーStateのプロパティ
export interface FeatureState {
  counter: number;
}

export interface AppState {
  feature: FeatureState;
}

// どのフィーチャーStateか定数として定義
export const selectFeature = createFeatureSelector<AppState, FeatureState>(featureKey);

// createSelectorの第一引数に欲しいフィーチャStateを指定
// 第二引数にどう取り出すか指定
// stateにはreducerで処理した返り値がStoreに格納され、そのcounterプロパティの値を取得する処理
export const selectFeatureCount = createSelector(
  selectFeature,
  (state: FeatureState) => state.counter
);
  • 定数featurekeyにフィーチャーのStateとして識別できる文字列で名前を定義します。
  • フィーチャーStateのプロパティをインターフェースとして定義
  • createFeatureSelector関数でどのフィーチャーStateかselectFeature定数に取り出す
  • createSelector関数の第一引数に欲しいフィーチャーStateを指定し、
    第二引数にどのようにStateを取り出すか指定
  • これで、createSelector関数でselectFeatureCountというセレクト関数ができました。

Selector経由でStoreからStateを取り出す

Storeから情報を選択して取り出すにはcreateSelector関数で定義されたセレクタ関数を利用します。

app.component.ts

export class AppComponent {
  counter$ = this.store.select(selectFeatureCount);

  constructor(private readonly store: Store) {}
}
  • Selector経由でStoreから取得されるStateはすでにReduceによって処理された後の値になります。
    つまり、initialStateではありません。

@ngrx/effects

@ngrx/effectsは@ngrx/storeと違い、NgRxのデフォルト機能ではありません。

別途インストールする必要があります。

npm install @ngrx/effects@XX --save

Effectとは公式ドキュメントによると下記のように記されています。

はじめに
サービスベースのAngularアプリケーションは、コンポーネントはサービスを通じて直接外部リソースとやりとりする責任があります。
その代わりに、Effectsはこれらのサービスと対話し、コンポーネントから分離するための方法を提供します。
Effectsはデータの取得、複数のイベントを生成する長時間タスク、その他コンポーネントが明示的な知識を必要としない外部とのやり取りなどのタスクを処理する場所です。

キーコンセプト
・Effectsはコンポーネントから副作用を分離し、状態の選択とアクションをdispatchし、コンポーネントをより純粋にする。
・EffectsはStoreからdispatchされるすべてのアクションのObservableをリッスンする、長時間稼働するサービスである。
・Effectsは興味のあるアクションの種類に基づいて、それらのアクションをフィルタリングします。これはOperatorsを使います。
・Effectsは同期または非同期のタスクを実行し、新しいアクションを返します。

、、、ということみたいです。

Effect

Effectとは簡単に言うと、発行されたActionを元にAPIにアクセスし、サービス経由でDBに「副作用」を与えるような処理。をまとめておく機能のことです。

💡 Effectに登録しておいたすべてのActionを監視→何らかのActionがdispache→マッチするActionを捕捉→何らかの副作用を与える処理→Actionを返す といった動きをします。

通常Actionがdispatchされると、Reducerに伝わり、そこで何らかの処理をしてStateを更新しますが、それとは別にDBにも副作用を与えたい時に、Effectを使って処理できます。

実際の動きのイメージは下記の通りです。

  1. 何らかのイベント等であるActionがdipatchされる
  2. Reducerで処理
  3. Effectが登録しておいた(dispacheされた)Actionを捕捉
  4. Effectで副作用を与える処理を実行し、その結果をもとに新たにActionを返す(dispatchする)

イメージはこんな感じですが、最後にActionを返さない(dispatch: false)や、Reducerでは処理せず、直接Effectだけで処理をするパターンもあります。

💡 なお、そのEffectの処理結果には、通信の成功と失敗の可能性がありますので、両方の処理に合わせてそれぞれ別のActionを用意する必要があります。

Effectを使う理由としては、コンポーネントの責任を軽減する役目があります。

コンポーネントベースのサイドエフェクトとの比較

サービスベースのアプリケーションでは、コンポーネントはプロパティやメソッドを介してデータを公開する多くの異なるサービスを通じてデータと対話します。
これらのサービスは他のデータセットを管理する他のサービスに依存することもあります。
よってコンポーネントはこれらのサービスを消費して、多くの責任を負うことになります。

★まずはコンポーネントベースのサイドエフェクトを見る

以下は映画を管理するアプリケーションの例です。
ここでは映画のリストを取得し、表示するコンポーネントがあります。

movies-page.component.ts

@Component({
  template: `
    <li *ngFor="let movie of movies">
      {{ movie.name }}
    </li>
  `
})

export class MoviesPageComponent {
  movies: Movie[];

  constructor(private movieService: MoviesService) {}

  ngOnInit() {
    this.movieService.getAll().subscribe(movies => this.movies = movies);
  }
}

movies.service.ts

@Injectable({
  providedIn: 'root'
})
export class MoviesService {
  constructor (private http: HttpClient) {}

  getAll() {
    return this.http.get('/movies');
  }
}

このコンポーネントには複数の責任があります。

  • 映画の状態を管理する
  • サービスを利用して副作用を実行し、外部APIにアクセスして映画を取得する
  • コンポーネント内で映画の状態を変更する

Storeと共に使用される場合、Effectsはコンポーネントの責任を軽減する。大規模なアプリケーションでは複数のデータソースがあり、それらのデータを取得するために複数のサービスが必要であり、サービスが他のサービスに依存する可能性があるため、これはより重要になります。

Effectsは外部のデータやインタラクションを処理するため、サービスはあまりステートフルにならず、外部のインタラクションに関連するタスクのみを実行することができます。

★次はEffectを利用してみる

コンポーネントをリファクタリングして、共有された映画データをStoreに格納します。Effectsは映画データの取得処理します。

movies-page.component.ts

@Component({
  template: `
    <div *ngFor="let movie of movies$ | async">
      {{ movie.name }}
    </div>
  `
})
export class MoviesPageComponent {
  movies$: Observable<Movie[]> = this.store.select(state => state.movies);

  constructor(private store: Store<{ movies: Movie[] }>) {}

  ngOnInit() {
    this.store.dispatch({ type: '[Movies Page] Load Movies' });
  }
}
  • 映画はまだ、MoviesServiceを通して取得されていますが、コンポーネントは映画の取得と読み込みの方法にはもはや関与していません。
  • コンポーネントは映画を読み込むことを宣言し、セレクタを使用してムービーリストのデータにアクセスする責任を負うだけです。
  • Effectsは映画を取得する非同期アクティビティが発生する場所です。コンポーネントはテストしやすくなり、必要なデータに対する責任も軽くなります。

Effectの定義方法

上記の例から映画の読み込みをどのように処理するかMovieEffectsを見てみます。

movie.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { EMPTY } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { MoviesService } from './movies.service';

@Injectable()
export class MovieEffects {

  loadMovies$ = createEffect(() => this.actions$.pipe(
    ofType('[Movies Page] Load Movies'),
    mergeMap(() => this.moviesService.getAll()
      .pipe(
        map(movies => ({ type: '[Movies API] Movies Loaded Success', payload: movies })),
        catchError(() => EMPTY)
      ))
    )
  );

  constructor(
    private actions$: Actions,
    private moviesService: MoviesService
  ) {}
}
  • loadMovies$はアクションストリームを通じてdispatchされたすべてのアクションを捕捉していますが、ofType演算子を使用して'[Movies Page] Load Movies'イベントを捕捉するように指示します。
  • 次に、アクションストリームはmergeMapオペレータを使用して、新しいobservableになり、マップされます。
  • MoviesService#getAll()メソッドは、成功時に映画を新しいアクションにマップするobservableを返し、現在エラーが発生すると空のobservableを返します。アクションはStoreにdispatchされ、状態変化が必要な時にReducerで処理されるようになります。
  • また、Effetcsの実行を継続させるために、observableストリームを扱うときにエラーを処理することも重要です。

💡 注意:イベントストリームはdispatchされたActionに限らず、Angular Routerからのobservable、ブラウザイベントから作成されたobservable、その他のobservableストリームなど、新しいアクションを生成するobservableなら何でもOKです。

Effectの登録

Effectが作成できたら、Effectを実行するためにAppModuleに登録する必要があります。EffectsModule.forRoot()メソッドにエフェクトの配列を追加して、appModuleに登録します。

app.module.ts

import { EffectsModule } from '@ngrx/effects';
import { MovieEffects } from './effects/movie.effects';

@NgModule({
  imports: [
    EffectsModule.forRoot([MovieEffects])
  ],
})
export class AppModule {}

💡 EffectsModule.forRoot() メソッドは、たとえルートレベルのエフェクトを登録しない場合でも、AppModule のインポートに追加する必要があります。

エフェクトは、AppModuleがロードされた直後に実行を開始し、関連するすべてのアクションをできるだけ早く捕捉することを保証します。

おわり

と、まあこんな感じでなんとなく基本は分かったような気がします。
次はNgRx公式チュートリアルについて記事にしようと思います。

参考

https://ngrx.io/guide/store

https://dev.classmethod.jp/articles/beginner-ngrx-angular-app/

https://qiita.com/puku0x/items/0a8e7224761dc549bd06

https://qiita.com/kitagawamac/items/8255c173c30831122648

https://www.isoroot.jp/blog/2232/

https://qiita.com/musou1500/items/8003c4a3f2b2e80d919f

https://blog.lacolaco.net/2019/02/learn-ngrx/#effects-について

https://qiita.com/Yamamoto0525/items/03155acbce1ffd1d037e

https://tercel-s.hatenablog.jp/entry/2018/08/13/180459#:~:text=Effect とは,ディスパッチすることもできる。

Discussion