はじめに
この記事はAngular AdventCalendar2018 14日目の記事です。
今までReact、Vueを使うことが多かったんですが、半年前からプロダクトでAngular + Ngrxを扱うことになりました。
Angularを使い始めて思ったのは、ググっても他のFWと比べると記事が少ないなと思うのと、Ngrxについてはさらに少ないな、と思ったのでこれからNgrxを使おうと思っている人が増えるようにNgrx初心向けの導入記事です。
Ngrxとは
Ngrx公式ページ。Angular.ioにあわせたドキュメントページが最近作られました。
https://ngrx.io/
NgRx Store provides reactive state management for Angular apps inspired by Redux. Unify the events in your application and derive state using Rxjs
Ngexは、Reduxを参考にAngularに状態管理を提供するライブラリです。Fluxの思想に従って、Componentで扱うStateをすべて一箇所のストアで一元管理し、Component間でのstateのやりとりを扱いしやすくします。
そもそもReduxって何?って方はReduxの入門記事を以前書いたのでご参照ください。
Ngrxの要素
前述の通り、NgRxはReduxと構成はほぼ同じです。データフローの簡単な遷移図がこちらです。
 
- Viewからイベントが発火され、Actionを作成
- ActionをStoreへdispatchする
- ReducerがActionを受けて新しいStateを作成&更新
- Selectorを通りViewへ新しいStateが渡る
それぞれの構成要素をチュートリアルのサンプルコードと共に解説していきます。
Action
Storeのstateを変更したい場合に直接変更は行えず、必ずActinonを作成してStoreへdispatchすることで変更を指示します。
import { Action } from '@ngrx/store';
 
export enum ActionTypes {
  Increment = '[Counter Component] Increment',
}
export class Increment implements Action {
  readonly type = '[Counter Component] Increment';
}
Actionはプロパティにユニーク値であるtypeを持ちます。
コンポーネントではActionをStoreへdispatchするために、コンストラクタからstoreを注入しておきます。ユーザーイベントに応じて毎回新規のActionを作成し、storeのdispatchメソッドを呼び出します。
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Increment } from '../counter.actions';
export class MyCounterComponent {
 
  constructor(private store: Store<{ count: number }>) {
  }
  increment() {
    this.store.dispatch(new Increment());
  }
}
<button (click)="increment()">Increment</button>
Store
アプリケーションのstateを保持する場所です。アプリケーション内に一つのみ存在します。
AppModuleにStoreModule.forRootを使いReducerと共に登録します。
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';
@NgModule({
  imports: [StoreModule.forRoot({ count: counterReducer })],
})
export class AppModule {}
Reducer
Reducerは、Actionと現在のstateに応じて新しいstateを作成するピュアなメソッドです。
また、stateの初期値の設定も行います。
import { Action } from '@ngrx/store';
import { ActionTypes } from './counter.actions';
 
export const initialState = 0;
 
export function counterReducer(state = initialState, action: Action) {
  switch (action.type) {
    case ActionTypes.Increment:
      return state + 1; 
    default:
      return state;
  }
}
selector
selectorは、Storeのstateの必要な部分のみを取得する為のものです。createSelectorのメソッドを使い、各stateを取得するselectorを登録します。
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
);
Effect
Effectは本来のReduxの機能には含まれておらず、redux-thunkやredux-sagaに該当するものです。外部APIとのHTTP通信など非同期処理を行う部分を担う箇所です。
Effectは、StoreへdispatchしたActionをキャッチして処理を行い、新しいActionをdispatchします。
Ngrxでもeffectはstoreのモジュールとは別になっています。
yarn add @ngrx/effects
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
 
@Injectable()
export class AuthEffects {
  // Listen for the 'LOGIN' action
  @Effect()
  login$: Observable<Action> = this.actions$.pipe(
    ofType('LOGIN'),
    mergeMap(action =>
      this.http.post('/auth', action.payload).pipe(
        // If successful, dispatch success action with result
        map(data => ({ type: 'LOGIN_SUCCESS', payload: data })),
        // If request fails, dispatch failed action
        catchError(() => of({ type: 'LOGIN_FAILED' }))
      )
    )
  );
 
  constructor(private http: HttpClient, private actions$: Actions) {}
}
Ngrxの内部実装
さて、Ngrxの概要について言及してきましたが、これをrxでどのように実現しているのか、内部実装を見て理解を深めましょう。
 
storeのdispatchメソッド
dispatchメソッドではactionObseverに対してactionをnextしています。
actionObserverはBehaviorSubjectで、disptchされるactionのストリームです。
dispatch<V extends Action = Action>(action: V) {
 this.actionsObserver.next(action);
}
stateとreducer
state自身はBehaviorSubjectで、actionOvserverをsubscribeしています。pipeしてreducerのストリームから登録されているredeucerをwithLatestFromで取得し、scanでreducerを実行して作られた新しいstateを自身のストリームに流しています。
この時同時にscannedActionsストリームにも新しいstateを流しています。
const actionsOnQueue$: Observable<Action> = actions$.pipe(
    observeOn(queueScheduler)
);
const withLatestReducer$: Observable<
    [Action, ActionReducer<any, Action>]
> = actionsOnQueue$.pipe(withLatestFrom(reducer$));
const seed: StateActionPair<T> = { state: initialState };
const stateAndAction$: Observable<{
    state: any;
    action?: Action;
}> = withLatestReducer$.pipe(
    scan<[Action, ActionReducer<T, Action>], StateActionPair<T>>(
    reduceState,
    seed
    )
);
this.stateSubscription = stateAndAction$.subscribe(({ state, action }) => {
    this.next(state);
    scannedActions.next(action);
});
effect
import { Actions, Effect, ofType } from '@ngrx/effects';
effectモジュールで使用するActionsは、上記でのscannedActionsにあたります。つまり、effectの実行タイミングは、storeがreducerの処理を行って新しいstateに変わった後のタイミングということがわかります。
constructor(@Inject(ScannedActionsSubject) source?: Observable<V>) {
    super();
    if (source) {
      this.source = source;
    }
  }
さいごに
Ngrxを導入するかどうかは、アプリの規模や開発人数に依ると思います。
とくにAngularsではserviceがStoreのようにsingletonな存在なので、service内に関連するstateを保持すれば状態管理はそんなに困らないです。
開発人数が増えてstateの更新が煩雑になってきている、テストしづらい、などで状態管理をしっかりしたいと感じた時にNgrxを入れることを考えてみてはどうでしょうか。
