はじめに
この記事は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を入れることを考えてみてはどうでしょうか。