【NgRx入門】カウンターアプリを作成方法をわかりやすく解説します!

この記事では『NgRx』について、

  • NgRxとは
  • NgRxの概念図
  • NgRxでカウンターアプリを作成する方法

などを図を用いて分かりやすく説明するように心掛けています。ご参考になれば幸いです。

NgRxとは

NgRxとは

NgRxはAngularアプリケーションの状態管理のためのライブラリで、RxJSをベースとして作られています。

NgRxを利用すると、Reduxのような状態管理を実現することができます。ReduxはReactアプリケーションで広く使用されている状態管理ライブラリで、Reduxsと同様の機能をAngularで提供することがNgRxの目的となっています。

NgRxの主要な機能の一つは、アプリケーションの状態を一箇所で一元管理することです。これにより、アプリケーションの任意の場所から状態にアクセスし、必要に応じて、その状態を更新することが可能になります。なお、状態を一元管理されている箇所は「Store」と呼ばれており、この状態は「Action」と「Reducer」を通して変更します。

後ほど詳しく説明しますが、Actionは何かが起こったという事実を表します(例えば、ユーザーがボタンをクリックしたなど)。Actionに対する反応として状態がどのように変わるかをReducerが決定します。

NgRxの概念図

NgRxの概念図

NgRxは上図のような構成になっています。NgRxの流れを以下に示します。

NgRxの流れ

  • 何らかのイベントが発生する(ユーザーがボタンをクリックしたなど)
  • イベントをトリガーとして、ActionがDispatch(送信)される。
  • ReducerがDispatch(送信)されたActionを受け取り、Storeが保持している状態(State)を更新する。
  • Selectorを使う場合は、SelectorでStoreから「一部の状態(State)」または「全体の状態(State)」を取得し、Componentに渡す。これにより、Componentは必要なデータだけを取得することができるようになる。
    Selectorを使わない場合は、Storeの状態(State)を直接Componentに渡す。
  • 「Dispatch(送信)されたAction」が「Effectが登録しているAction」だった場合、そのActionを捕捉してServiceを呼び出し、副作用が行われる処理を行う。また、Effectは新たなActionをDispatch(送信)することも可能で、それによりアプリケーションの状態を更に更新することもできる。

「今回作成するカウンターアプリの画面」と「NgRxの概念図」を以下に示します。今回作成するカウンターアプリをNgRxの流れに当てはめると以下の様になります。

NgRxの流れ

「カウンターアプリの画面」と「NgRxの概念図」
  • イベントが発生する(今回は一例として、ユーザーがincrementボタンをクリックする)
  • イベントをトリガーとして、incrementアクションがDispatch(送信)される。
  • ReducerがDispatch(送信)されたincrementアクションを受け取り、Storeが保持している状態(State)を更新する。今回作成したカウンターアプリでは、カウンターの値(count)を「1」プラスしています。
  • SelectorでStoreから「カウンターの値(count)」を取得し、Componentに渡すことで、画面に表示している「Counter: 数値」の数値の箇所が更新される。
  • Effectがincrementアクションを補足し、副作用が行われる処理を行う。今回は説明のために、console.log関数でログ出力のみを行う。

では実際にカウンターアプリをNgRxを用いて作成してみましょう。

NgRxでカウンターアプリを作成する方法

counter-app                       //アプリ名
├─src                           
│   ├─app                       
│   │   ├─app.module.ts           //★Appモジュール
│   │   ├─app.component.ts        //★Appコンポーネント
│   │   ├─app.state.ts            //★Store
│   │   ├─counter.actions.ts      //★Action
│   │   ├─counter.reducer.ts      //★Reducer
│   │   ├─counter.selector.ts     //★Selector
│   │   └─counter.effects.ts      //★Effect
│   ├─main.ts
│   ├─index.html
│   └─styles.css
├─angular.json
├─package-lock.json
├─package.json
├─tsconfig.app.json
├─tsconfig.json
└─node_modules

ディレクトリ構成は上図のようになっています。NgRxでカウンターアプリを作成するためには、上記の★マークで示したファイルを作成する必要があります。

補足

説明をシンプルにするために、作成したすべてのファイルはappフォルダの中に置いています。

大規模なアプリケーションでは通常、各機能ごとにフォルダを作成し、その中に各機能に関連するファイルを配置するという構成が多いです。そのため、実際の開発では、appフォルダに全てのファイルを置くのではなく、適切にフォルダを作成して整理することが推奨されます。

必要なパッケージをインストールする

まず、NgRxに必要なパッケージをインストールする必要があります。

カレントディレクトリ(現在作業しているディレクトリ)がプロジェクトのルートディレクトリ(package.jsonファイルが存在するディレクトリ)であることを確認してください。その後、「PowerShell」または「コマンドプロンプト(cmd)」で、以下のコマンドを入力すると、「@ngrx/store」と「@ngrx/effects」というパッケージをインストールすることができます。

@ngrx/storeはアプリケーションの状態を管理するためのライブラリで、@ngrx/effectsはアプリケーションの非同期処理(外部リソースへのアクセスなど)を管理するためのライブラリです。

npm install @ngrx/store @ngrx/effects

Actionの作成

以下のプログラムは、今回作成した「Action(counter.actions.tsファイル)」の一例です。

counter.actions.ts

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

export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');

// 1.props関数を用いる場合
 export const setCount = createAction('[Counter] Set Count', props<{ count: number }>());

// 2.props関数を用いない場合
// export const setCount = createAction('[Counter] Set Count', (payload: { count: number }) => ({ payload }));
// export const setCount = createAction('[Counter] Set Count', (hoge: { count: number }) => ({ payload: hoge })); の省略記法

// 3.props関数を用いない場合(payloadのプロパティが1つの場合は以下の記述方法でも可能)
// export const setCount = createAction('[Counter] Set Count', (count: number) => ({ count }));
// export const setCount = createAction('[Counter] Set Count', (hoge: number) => ({ count: hoge })); の省略記法

Storeが保持している状態(State)を直接変更することはできません。状態(State)を変更するには、まずActionを定義し、ActionをDispatch(送信)することで、状態(State)の変更を行います。

上記のプログラムでは、以下の4つのActionを定義しています(ActionCreatorについては後ほど説明します)。

  • incrementアクション
    • カウンターをインクリメントするActionCreatorと呼ばれる関数
  • decrementアクション
    • カウンターをデクリメントするActionCreatorと呼ばれる関数
  • resetアクション
    • カウンターをリセットする(カウンターの値を0にする)ActionCreatorと呼ばれる関数
  • setCountアクション
    • カウンターの値を指定した数値に設定するActionCreatorと呼ばれる関数

これらのActionの作成方法をこれから説明します。

@ngrx/storeライブラリが提供している「createAction関数」はActionCreatorと呼ばれる関数を返します。そのため、「createAction関数」を用いることで、Action(正確にはActionCreator)を作成することができます。なお、ActionCreatorはActionオブジェクトを返す関数です(これらの意味が分からなくても、この記事を読んでいくと分かるようになると思います)。

「createAction関数」の「第1引数」と「第2引数」は、以下のように書きます。

createAction関数

  • 第1引数
    • 作成するAction(ActionCreator)の名前を指定します。’[カテゴリ名] イベント名’にすると分かりやすいのでお勧めです。
  • 第2引数
    • 引数をとるAction(ActionCreator)の場合、第2引数に関数を渡します。なお、上記のプログラムの場合、setCountアクションでは、指定した数値を設定したいため、引数をとるAction(ActionCreator)になります。
    • 第2引数には、@ngrx/storeライブラリが提供している「props関数」を用いると、簡潔に書くことができます。
    • 「props関数」はオブジェクトの形式で「Actionのペイロード(ActionがReducerに運ぶActionオブジェクトの一部)」の構造を定義します。
    • 上記のプログラムの、props<{ count: number }>()は「countプロパティの値がnumber型であるペイロードである」ということを示しています。

なお、上記のプログラムでは@ngrx/storeライブラリから提供されている「createAction関数」と「prop関数」を用いるため、以下に示すようにインポートすることを忘れないでください。

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

補足

上記のプログラムでは、exportを使って作成したActionを公開することで、アプリケーション内にある他のコンポーネント・サービス・モジュールから作成したAction(ActionCreator)を利用することが可能にしています。

createAction関数のプログラム例

プログラム例1

「createAction関数」を以下のように記述した場合を考えてみます。

export const increment = createAction('[Counter] Increment');

上記のプログラムの場合、「createAction関数」は「第1引数」のみを取っており、incrementという「ActionCreator」を作成しています。

このように「ActionCreator」を作成した場合、Dispatchは以下のように行います(以下のプログラムは後ほど説明する「app.component.ts」に記述しています)。

this.store.dispatch(increment());

「ActionCreator」は「Actionオブジェクト」を返す関数なので、上記のようにDispatchすると、以下の「Actionオブジェクト」が生成されます。

{
  type: '[Counter] Set Count'
}

プログラム例2

「createAction関数」を以下のように記述した場合を考えてみます。

export const setCount = createAction('[Counter] Set Count', props<{ count: number }>());

上記のプログラムの場合、「createAction関数」は「第1引数」と「第2引数」を取っており、setCountという「ActionCreator」を作成しています。これは、ActionをDispatchすると、{ count: number }という形式のペイロード(ActionがReducerに運ぶActionオブジェクトの一部)が生成されることを示しています。

このように「ActionCreator」を作成した場合、Dispatchは以下のように行います。setCountは引数をとる「ActionCreator」なので、{ count: 10 }を引数に渡しています。

this.store.dispatch(setCount({ count: 10 }));

「ActionCreator」は「Actionオブジェクト」を返す関数なので、上記のようにDispatch(送信)すると、「createAction関数」の「第1引数」から作成されるtype: '[Counter] Set Count'とマージされ、以下の「Actionオブジェクト」が生成されます。

{
  type: '[Counter] Set Count',
  count: 10
}

「Actionオブジェクト」は「typeプロパティ」と呼ばれる特定のプロパティを持ち、「createAction関数」の「第1引数」に指定した「Action(ActionCreator)」の名前が「typeプロパティ」の値になります。

Actionの作成方法(props関数を用いない場合)

props関数を用いない場合、以下の2つの方法でAction(ActionCreator)を作成することができます。

方法1

export const setCount = createAction('[Counter] Set Count', (payload: { count: number }) => ({ payload }));
// 省略記法を用いない場合は、以下のプログラムでも可能(あまりみかけない)
export const setCount = createAction('[Counter] Set Count', (hoge: { count: number }) => ({ payload: hoge }));

この方法の場合、ActionをDispatchすると、payload: { count: number }という形式のペイロード(ActionがReducerに運ぶActionオブジェクトの一部)が生成されることを示しています。

このように「ActionCreator」を作成した場合、Dispatchは以下のように行います。setCountは引数をとる「ActionCreator」なので、{ count: 10 }を引数に渡しています。

this.store.dispatch(setCount({ count: 10 }));

「ActionCreator」は「Actionオブジェクト」を返す関数なので、上記のようにDispatch(送信)すると、「createAction関数」の「第1引数」から作成されるtype: '[Counter] Set Count'とマージされ、以下の「Actionオブジェクト」が生成されます。

{
  type: '[Counter] Set Count',
  payload: { count: 10 }
}

方法2

ペイロードのプロパティが1つの場合、以下のように記述することも可能です。

export const setCount = createAction('[Counter] Set Count', (count: number) => ({ count }));
// 省略記法を用いない場合は、以下のプログラムでも可能(あまりみかけない)
export const setCount = createAction('[Counter] Set Count', (hoge: number) => ({ count: hoge }));

この方法の場合、ActionをDispatchすると、{ count: number }という形式のペイロード(ActionがReducerに運ぶActionオブジェクトの一部)が生成されることを示しています。

このように「ActionCreator」を作成した場合、Dispatchは以下のように行います。setCountは引数をとる「ActionCreator」なので、10を引数に渡しています。

this.store.dispatch(setCount(10));

「ActionCreator」は「Actionオブジェクト」を返す関数なので、上記のようにDispatch(送信)すると、「createAction関数」の「第1引数」から作成されるtype: '[Counter] Set Count'とマージされ、以下の「Actionオブジェクト」が生成されます。

{
  type: '[Counter] Set Count',
  count: 10
}

「方法2」はよりシンプルで直感的に見えますが、ペイロードが複数のプロパティを必要とする場合には使用できません。

Storeの作成

以下のプログラムは、今回作成した「Store(app.state.tsファイル)」の一例です。

app.state.ts

export interface CounterState {
  count: number;
};
export const initialState: CounterState = {
  count: 0
};
export interface AppState {
  counter: CounterState;
};

これらのStoreの作成方法をこれから説明します。

まず、状態(State)を管理するためのインターフェースを定義します。

以下のプログラムでは、カウンターの状態を管理するためのインターフェース(CounterState)を定義しています。この「CounterState」では、カウンターの値(countプロパティ)のみを管理しています(実際の開発では多くの状態を管理していますが、今回はシンプルに説明するために、カウンターの値のみを管理するようにしています)。

export interface CounterState {
  count: number;
};

次に状態(State)の初期状態を定義します。

以下のプログラムでは、カウンターの状態を管理する「CounterState」の初期状態を「initialState」として定義しています。この「initialState」では、カウンターの値(countプロパティ)の初期状態を「0」としています。

export const initialState: CounterState = {
  count: 0
};

最後に、アプリケーション全体の状態(State)を管理するためのインターフェースを定義します。

以下のプログラムでは、アプリケーション全体の状態を管理するためのインターフェース(AppState)を定義しています。この「AppStateインターフェース」ではアプリケーションの各機能が管理している状態をプロパティの値に書きます。

export interface AppState {
  counter: CounterState;
};

上記のプログラムの場合、AppStateは「counterプロパティ」のみを持ち、その値はカウンターの状態を管理している「CounterState」となります。このように定義することで、アプリケーション全体の状態(AppState)から特定の状態(CounterStateのcount)を選択するSelectorを作成することが容易になります(Selectorについては後ほど説明します)。

状態管理にNgRxを使用するとき、各機能ごとに状態(State)を管理しますが、それらを一つの大きなオブジェクトとしてまとめるためにルートステートが必要になります。このルートステートがAppStateとなります。

Reducerの作成

以下のプログラムは、今回作成した「Reducer(counter.reducer.tsファイル)」の一例です。

counter.reducer.ts

import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset, setCount } from './counter.actions';
// 以下のように記述するとActionをまとめてインポートできます。
// import * as CounterActions from './counter.actions';
// この場合は、CounterActions.Action()メソッドでアクセスできます。

import { initialState } from './app.state';

export const counterReducer = createReducer(
  initialState,
  on(increment, (state) => ({ ...state, count: state.count + 1 })),
  on(decrement, (state) => ({ ...state, count: state.count - 1 })),
  on(reset, (state) => ({ ...state, count: 0 })),

  // 1.props関数を用いる場合
  on(setCount, (state, action) => ({ ...state, count: action.count }))
  // on(setCount, (state, { count }) => ({ ...state, count }))でも可能
  // 上記は on(setCount, (state, { count: hoge }) => ({ ...state, count: hoge })) の省略記法

  // 2.props関数を用いない場合
  // on(setCount, (state, action) => ({ ...state, count: action.payload.count }))
  // on(setCount, (state, { payload: { count } }) => ({ ...state, count }))でも可能
  // 上記は on(setCount, (state, { payload: { count: hoge } }) => ({ ...state, count: hoge })) の省略記法

  // 3.props関数を用いない場合(payloadのプロパティが1つの場合は以下の記述方法でも可能)
  // on(setCount, (state, action) => ({ ...state, count: action.count }))
  // on(setCount, (state, { count }) => ({ ...state, count }))でも可能
  // 上記は on(setCount, (state, { count: hoge }) => ({ ...state, count: hoge })) の省略記法
);

ReducerはActionがDispatch(送信)されると呼び出され、アプリケーションのStoreが保持している状態(State)を変化させる処理を行います。具体的にReducerは、現在の状態(State)とActionを元に新しい状態(State)を生成し、新しい状態(State)をアプリケーションのStoreに反映しています。

createReducer関数は「初期状態」と「各アクションに対する処理」を記述します。createReducer関数の「第1引数」と「第2引数以降」は、以下のように書きます。

createReducer関数

  • 第1引数
    • 初期状態を指定します。
    • 上記のプログラムでは、カウンターの初期状態(initialState)を指定しています(「Storeの作成」で説明しましたが、「initialState」では「CounterState」の「countプロパティ」を0として初期化しています)。
  • 第2引数以降
    • createReducer関数の第2引数以降では、on()メソッドを使用し、各アクションに対して、状態(state)がどのように変化するかをを書きます。

on()メソッドの「第1引数」と「第2引数」は、以下のように書きます。

on()メソッド

  • 第1引数
    • 「createAction関数」で作成したAction(ActionCreator)を書きます。
  • 第2引数
    • 関数を書きます(上記のプログラムではアロー関数を書いています)。関数では「現在の状態(state)」を受け取り、「新しい状態(state)」を返すように定義します。

以下は、on()メソッドの使用方法のプログラム例です。

on(increment, (state) => ({ ...state, count: state.count + 1 })),
  • 第1引数
    • 「createAction関数」にて、作成したAction(ここでは、increment)を書いています。
  • 第2引数
    • アロー関数を用いており、引数として、「現在の状態(state)」を受け取り、「新しい状態(state)」を返しています。上記のプログラムでは、「CounterStateの現在の状態(state)」を引数で受け取っています。
    • 返り値の「…state」は、スプレッド構文(…)を使用しており、引数で受け取った「CounterStateの現在の状態(state)」の全てのプロパティを「新しい状態(state)」にコピーしています。
    • count: state.count + 1では、「CounterStateの現在の状態(state)」の「countプロパティ」の値(state.count)に「1」を加えたものを「新しい状態(state)」の「countプロパティ」の値にしています。

なお、上記のプログラムでは@ngrx/storeライブラリから提供されている「createReducer関数」と「on関数」を用いるため、インポートすることを忘れないでください。

import { createReducer, on } from '@ngrx/store';

また、各アクションは「counter.actions.tsファイル」からインポートすることを忘れないでください。

import { increment, decrement, reset, setCount } from './counter.actions';

「カウンターの初期状態(initialState)」も「app.state.tsファイル」からインポートする必要があります。

import { initialState } from './app.state';

補足

on()メソッドの第1引数に「作成したAction(ActionCreator)」がない場合、そのActionではReducerにより状態(State)の更新を行っていないことを意味しています。

Effectの作成

以下のプログラムは、今回作成した「Effect(counter.effects.tsファイル)」の一例です。

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs/operators';
import { increment, decrement } from './counter.actions';
// 以下のように記述するとActionをまとめてインポートできます。
// import * as CounterActions from './counter.actions';
// この場合は、CounterActions.Action()メソッドでアクセスできます。

@Injectable()
export class CounterEffects {

  constructor(private actions$: Actions) { }

  increment$ = createEffect(() =>
    this.actions$.pipe(
      ofType(increment),
      tap(() => console.log('Increment Action Dispatched'))
    ),
    { dispatch: false }
  );

  decrement$ = createEffect(() =>
    this.actions$.pipe(
      ofType(decrement),
      tap(() => console.log('Decrement Action Dispatched'))
    ),
    { dispatch: false }
  );
}

EffectはActionがDispatch(送信)されると、Effectに登録しているActionを捕捉し、副作用を与える処理を実行しています。

Effectの動作イメージは以下となっています。

Effectの動作イメージ

  • Effectは全てのActionを監視している。
  • 何らかのActionがDispatch(送信)される。
  • 「Dispatch(送信)されたAction」が「Effectに登録しているAction」だった場合、そのActionを捕捉する。
  • 副作用を与える何らかの処理を実行する。
  • その結果を元に、新しいActionを返す(Actionを返さない場合には、{ dispatch: false }を用いる).。

通常、ActionがDispatch(送信)されると、Reducerに伝わり、Reducerで何らかの処理を実行して、Stateを更新します。この処理とは別に何か副作用を与えたいときにEffectを用います。

Effectの作成方法をこれから説明します。

まず、コンストラクタを作成します。

constructor(private actions$: Actions) { }

上記のプログラムでは、「Actions型」のインスタンスを「actions$プロパティ」として宣言し、同時にコンストラクタ内で初期化しています。これにより、このコンストラクタを持つクラス(ここでは、CounterEffects)内において、「this. actions$.~」の形式でこのクラスのどこからでもアクセスできるようになります。なお、Actionsは@ngrx/effectsライブラリから提供されるActionsサービスのインスタンスです。

次に、各Effectを「createEffect関数」を用いて作成します。

以下のプログラムでは、increment$という名前のEffectを作成しています。

increment$ = createEffect(() =>
  this.actions$.pipe(
    ofType(increment),
    tap(() => console.log('Increment Action Dispatched'))
  ),
  { dispatch: false }
);

Effectを作成するためには、「createEffect関数」を用います。「createEffect関数」の返り値は「Observable」です。上記のプログラムでは「Observable(increment$)」にはDispatch(送信)されたActionが流れていますが、「ofTypeオペレータ」により、「increment」のActionのみを捕捉しています(ofTypeオペレータでは、複数のアクションを捕捉することもできます)。

そのため、上記のプログラムは、「increment」のActionがDispatch(送信)されると、「ofTypeオペレータ」により捕捉し、その後に「tapオペレータ」により「console.log関数」でログを出力しています。

ofTypeオペレータ

「ofTypeオペレータ」は「@ngrx/effectsライブラリ」から提供される「RxJSオペレータ(pipeメソッドの引数)」です。「ofTypeオペレータ」は、「アクションストリーム(DispatchされたActionの流れ)」から特定のActionをフィルタリングするために使用します。

「ofTypeオペレータ」の引数には、「ActionCreatorの名前」または「createAction関数の第1引数で指定した名前」を書きます。そのため、以下のように記述します。

// Actionの作成を以下のように行った場合
export const increment = createAction('[Counter] Increment');
// Effectの「ofTypeオペレータ」で捕捉するときは、以下のどちらかを記述する
ofType(increment)
ofType('[Counter] Increment')

tapオペレータ

「tapオペレータ」は「rxjs/operatorsライブラリ」から提供される「RxJSオペレータ(pipeメソッドの引数)」です。「tapオペレータ」は値を取得して処理を追加することができます。また、「アクションストリーム(DispatchされたActionの流れ)」に影響のない処理を行っているため、後続の処理には影響を与えません。

「tapオペレータ」の最も一般的な使い方は、今回のプログラムに示すようなデバッグ用です。例えば、tap(console.log)を「pipe()メソッド」の任意の場所に配置すれば、前の動作の返り値をログ出力することができます。

{ dispatch: false }

createEffect関数に渡すオプションオブジェクトです。{ dispatch: false }オプションを使用することで、Effectが新しいActionをDispatch(送信)しないように設定することができます。つまり、Effect内で別のActionを実行することがなくなります。

今回は説明のために、console.log関数でログを出力しましたが、実際には、HTTP通信をしたり、別のActionを実行したりなど「副作用」を与えるような処理を行います。ここでいう副作用は通常、「関数やメソッドの実行中に行われる外部のアクションや状態変更」を指します。例えば、データベースへの読み書き、ネットワークリクエストなどが副作用の例です。

Selectorの作成

以下のプログラムは、今回作成した「Selector(counter.selector.tsファイル)」の一例です。

// ★createFeatureSelectorを使用しない場合
import { createSelector } from '@ngrx/store';
import { AppState } from './app.state';
import { CounterState } from './app.state';

export const selectCounterState = (state: AppState) => state.counter;
// 上記のコードは以下のように変えても動作可能
// export const selectCounterState = (state: CounterState) => state;

export const selectCount = createSelector(
  selectCounterState,
  (state: CounterState) => state.count
);

// ★createFeatureSelectorを使用する場合
// import { createFeatureSelector, createSelector } from '@ngrx/store';
// import { CounterState } from './app.state';

// export const selectCounterState = createFeatureSelector<CounterState>('counterStateKey');

// export const selectCount = createSelector(
//   selectCounterState,
//   (state: CounterState) => state.count
// );

Selectorはデータの取り出し方法を関数として定義したものです。Selectorを作成すると、NgRxのStore内の状態(State)から必要なデータを取り出すのに役立ちます。

Selectorの作成方法をこれから説明します。

まず、アプリケーション全体の状態(AppState)から、特定の状態を取り出す「Selector関数」を定義します。

export const selectCounterState = (state: AppState) => state.counter;

上記のプログラムでは、アプリケーション全体の状態(AppState)から、カウンターの状態(CounterState)を取り出す「Selector関数」を定義しています(AppStateのCounterプロパティの値はCounterStateなので)。

補足

「createFeatureSelector関数」を用いると、以下のプログラムでも、カウンターの状態(CounterState)を取り出す「Selector関数」を定義することができます。

export const selectCounterState = createFeatureSelector<CounterState>('counterStateKey');

最後の括弧の中の文字列(上記のプログラムでは、'counterStateKey')には任意の文字列を入力することができます。なお、後ほど説明しますが、この括弧の中の文字列はAppモジュール(app.module.tsファイル)で記述している「StoreModule.forRoot()のプロパティ名」と同じにする必要があります。

次に、各Selectorを「createSelector関数」を用いて作成します。

export const selectCount = createSelector(
  selectCounterState,
  (state: CounterState) => state.count
);

「createSelector関数」は複数の「Selector関数」を組み合わせて新しい「Selector」を作成するのに使われます。また、「createSelector関数」は複数の引数を取り、最後の引数は「プロジェクター関数」と呼ばれます。「プロジェクター関数」は、前の全ての「Selector関数」の結果を引数として受け取り、それを組み合わせて最終的な結果を生成します。つまり、どのように状態(State)を取り出すのかを指定するのが「プロジェクター関数」です。

上記のプログラムでは、まず、「Selector関数(selectCounter)」で「アプリケーション全体の状態(AppState)」から「カウンターの状態(CounterState)」を取り出しています。その後、「プロジェクター関数」により、「カウンターの状態(CounterState)」から「countプロパティの値(カウンターの現在の値)」を取り出しています。つまり、上記のプログラムでは、「アプリケーション全体の状態(AppState)」から「カウンターの現在の値(count)」を取り出す「Selector」を作成しています。

これにより、「アプリケーション全体の状態(AppState)」が与えられたとき、「selectCountと名付けたSelector」を用いることで「カウンターの現在の値(CounterState.count)」を直接取得することが可能になります。

「Selector関数」と「Selector」の命名

今回は、

  • Selector関数:selectCounterState
  • Selector:selectCount

と名付けましたが、

  • Selector関数:getCounterState
  • Selector:getCount

のように、「select」ではなく「get」を使うプログラムもよく見かけます。

補足

SelectorによりStoreから取得される状態(State)は既にReducerによって処理された後の状態(State)になります。つまり、初期状態(今回のプログラムでいうinitialState)ではありません。

Appコンポーネントの作成

以下のプログラムは、今回作成した「Appコンポーネント(app.component.tsファイル)」の一例です。

// app.component.ts

import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { CounterState, AppState } from './app.state';
import { increment, decrement, reset, setCount } from './counter.actions';
// 以下のように記述するとアクションをまとめてインポートできます。
// import * as CounterActions from './counter.actions';
// この場合は、CounterActions.Actionメソッドでアクセスできます。

import { selectCount } from './counter.selector';
// 以下のように記述するとセレクターをまとめてインポートできます。
// import * as CountSelectors from './counter.selector';
// この場合は、CountSelectors.Selectorでアクセスできます。

@Component({
  selector: 'app-root',
  template: `
    <h1>Counter: {{ count$ | async }}</h1>
    <button (click)="increment()">Increment</button>
    <button (click)="decrement()">Decrement</button>
    <button (click)="reset()">Reset</button>
    <button (click)="setCount(10)">Set Count to 10</button>
  `
})
export class AppComponent {
  count$: Observable<number>;

  constructor(private store: Store<AppState>) {
    this.count$ = this.store.pipe(select(selectCount));
  }

  // 以下のプログラムでも可能(なお、Selectorを使う場合はStore<AppState>ではなくStore<CounterState>でも可能)
  // constructor(private store: Store<AppState>) { }
  // count$ = this.store.pipe(select(selectCount));

  // Selectorを用いない場合は、以下のプログラムでも可能
  // constructor(private store: Store<AppState>) {
  //   this.count$ = this.store.pipe(select(state => state.counter.count));
  // }

  increment() {
    this.store.dispatch(increment());
  }
  decrement() {
    this.store.dispatch(decrement());
  }
  reset() {
    this.store.dispatch(reset());
  }
  setCount(count: number) {
    // 1.propsを用いる場合
    this.store.dispatch(setCount({ count }));
    // this.store.dispatch(setCount({ count: count }));の省略記法

    // 2.propsを用いない場合
    // this.store.dispatch(setCount({ count }));
    // this.store.dispatch(setCount({ count: count }));の省略記法

    // 3.propsを用いない場合(プロパティが1つの場合は以下の記述方法でも可能)
    // this.store.dispatch(setCount( count ));
  }
}

まず、Observableを定義します。

count$: Observable<number>;

上記のプログラムでは、count$という名前のObservableを定義します。Observableは、時間とともに値が変化する可能性があるものを表します。

次に、コンストラクタを作成します。

constructor(private store: Store<AppState>) {
  this.count$ = this.store.pipe(select(selectCount));
}

private store: Storeは、アプリケーション全体の状態(AppState)を保持しているStoreを「storeプロパティ」として宣言し、同時にコンストラクタ内で初期化しています。これにより、このコンストラクタを持つクラス(ここでは、AppComponent)内において、「this. store.~」の形式でこのクラスのどこからでもアクセスできるようになります。

selectCountは作成したSelectorです。selectCountではカウンターの現在の値(CounterState.count)を取得しています。

すなわち、this.count$ = this.store.select(selectCount);では、アプリケーション全体の状態(AppState)を保持しているStoreから、カウンターの現在の値(CounterState.count)を取得することを選択(select)し、それをcount$という名前のObservableに割り当てています。

そのため、カウンターの現在の値(CounterState.count)が変化すると、テンプレートの中にある{{ count$ | async }}が受け取って値が変更されます。

Angularテンプレートで「asyncパイプ」を使用する理由

count$という名前のObservableをテンプレートで表示する際に、{{ count$ | async }}と「asyncパイプ」を使っています。

これは、テンプレート(ビュー)側では、Observableを直接出力できないからです。「asyncパイプ」を使うことでテンプレート側でObservableを受け取り、Observableが値を返したタイミングで、その値を取り出すことができるようになります。

なお、「asyncパイプ」がないと、[object Object]のような意味のない出力になります。

Appモジュールの作成

以下のプログラムは、今回作成した「Appモジュール(app.module.tsファイル)」の一例です。

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { counterReducer } from './counter.reducer';
import { CounterEffects } from './counter.effects';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({ 'counter': counterReducer }),
    EffectsModule.forRoot([CounterEffects])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

@NgModuleデコレーターのimportパラメータに「StoreModule.forRoot関数」を追加し、「プロパティ名」と「カウンターの状態を管理するReducer」を指定することで、アプリケーション全体でアクセスできるようになります。

「カウンターの状態を管理するReducer」は、今回のプログラムの場合counterReducerになります。

プロパティ名の決め方は少し複雑です。

「StoreModule.forRoot関数」のプロパティ名の決め方

  • Selectorの作成で「createFeatureSelector関数」を使用しない場合
    • 「カウンターの状態(CounterState)を管理しているプロパティ名」と「StoreModule.forRoot関数のプロパティ名」を同じにします。以下のプログラムではcounterを同じにしています。
// selector.tsでの記述
export const selectCounterState = (state: AppState) => state.counter;
// app.module.tsでの記述
StoreModule.forRoot({ 'counter': counterReducer }),
  • Selectorの作成で「createFeatureSelector関数」を使用する場合
    • 「createFeatureSelector関数の括弧内の文字列」と「StoreModule.forRoot関数のプロパティ名」を同じにします。以下のプログラムではcounterStateKeyを同じにしています。
// selector.tsでの記述
export const selectCounterState = createFeatureSelector<CounterState>('counterStateKey');
// app.module.tsでの記述
StoreModule.forRoot({ 'counterStateKey': counterReducer }),

@NgModuleデコレーターのimportパラメータに「EffectsModule.forRoot関数」を追加し、Effectsを指定することで、でアプリケーション全体でアクセスできるようになります。

「.forRoot()」と「.forFeature()」の違い

「StoreModule.forRoot()」と「EffectsModule.forRoot()」は、アプリケーションのルートレベル(アプリケーション全体に影響を与えるレベル)で呼び出す時に使用します。

一方、「StoreModule.forFeature()」と「EffectsModule.forFeature()」は、フィーチャーモジュール(アプリケーションの特定の部分または機能を担当するモジュール)内で呼び出す時に使用します。

以上で準備が完了です。では実際にアプリを実行してみましょう。実行結果は以下のようになります。

プログラムの実行結果

NgRxで作成したカウンターアプリ

「increment」ボタンをクリックすると、カウンターの値が増加します。また、ログに「Increment Action Dispatched」が表示されます。

「decrement」ボタンをクリックすると、カウンターの値が減少します。また、ログに「Decrement Action Dispatched」が表示されます。

「Reset」ボタンをクリックすると、カウンターの値をリセットします(カウンターの値を0にします)。

「Set Count to 10」ボタンをクリックすると、カウンターの値が10に設定されます。

本記事のまとめ

この記事では『NgRx』について、以下の内容を説明しました。

  • NgRxとは
  • NgRxの概念図
  • NgRxでカウンターアプリを作成する方法

お読み頂きありがとうございました。