angular
RxJS
redux
ngrx

@ngrx/storeと@ngrx/effectsの使い方

アプリの規模が大きくなると、状態管理をどうするかが課題になってきますよね?

状態管理のためのライブラリとしてはReact Reduxが有名ですが、AngularにもNgRxというライブラリがあります。

ngrx

本記事では、簡単なサンプルを通して@ngrx/store@ngrx/effectsの使い方を紹介します。
※ サンプルはNgRx v4.x系で書かれています

記事の対象者

  • Angularを利用している方
  • Reduxのような状態管理をしたい方

@ngrx/store

Angular用の状態管理ライブラリです。
RxJSベースで作られており、ReduxのようにAction、Reducer、Storeの概念があります。

@ngrx/effects

発行されたアクションを元にWebAPIにアクセスするといった「副作用」を処理するライブラリ(redux-sagaやredux-thunkのようなもの)です。

インストール

npmを使って作成済みのAngularアプリにインストールしてください。

$ npm install @ngrx/store
$ npm install @ngrx/effects

Action

@ngrx/storeActionインターフェースを使ってActionを作成します。
下記の例では「ToDoの新規作成」の場合を記載しています。

actions/todo.action.ts
import { Action } from '@ngrx/store';
import { Todo } from '../todo';

export const CREATE         = '[Todo] Create';
export const CREATE_SUCCESS = '[Todo] Create Success';
export const CREATE_FAILURE = '[Todo] Create Failure';

/**
 * 作成
 */
export class Create implements Action {
  readonly type = CREATE;
  constructor(public payload: Todo) {}
}

/**
 * 作成成功
 */
export class CreateSuccess implements Action {
  readonly type = CREATE_SUCCESS;
  constructor(public payload: Todo) {}
}

/**
 * 作成失敗
 */
export class CreateFailure implements Action {
  readonly type = CREATE_FAILURE;
  constructor(public payload?: any) {}
}

export type Actions = Create | CreateSuccess | CreateFailure;

ペイロードに型が付くので安心ですね!

モデルは以下のようなものを想定しています。

todo.ts
export class Todo {
  constructor(
    public id?: number,
    public content?: string,
  ) { }
}

Reducer

Reducerには各Actionに対して状態がどのように遷移するかを書きます。
状態の定義は同じファイルに書いておくと良いと思います。

reducers/todo.reducer.ts
import { Action, createSelector, createFeatureSelector } from '@ngrx/store';
import * as TodoAction from '../actions/todo.action';
import { Todo } from '../todo.ts';

/**
 * 状態
 */
export interface State {
  loading: boolean;
  todos: Todo[];
}

/**
 * 初期状態
 */
export const initialState = {
  loading: false,
  todos: [],
};

/**
 * Reducer
 */
export function reducer(state = initialState, action: TodoAction.Actions): State {
  switch (action.type) {
    case TodoAction.CREATE: {
      // 作成
      return Object.assign({}, state, { loading: true });
    }
    case TodoAction.CREATE_SUCCESS: {
      // 作成成功したら一覧に追加
      return Object.assign({}, state, { loading: false, todos: [...state.todos, action.payload] });
    }
    case TodoAction.CREATE_FAILURE: {
      // 作成失敗
      return Object.assign({}, state, { loading: false });
    }
    default: {
      return state;
    }
  }
}

/**
 * セレクタ(Storeから特定の状態を取得する)
 */
export const getState = createFeatureSelector<State>('todo');
export const getLoading = createSelector(getState, state => state.loading);
export const getTodos = createSelector(getState, state => state.todos);

Reducerは使いやすいようにまとめておきましょう。

reducers/index.ts
import { ActionReducerMap } from '@ngrx/store';
import * as fromTodo from './todo.reducer';

/**
 * アプリ全体の状態
 */
export interface State {
  todo: fromTodo.State;  // createFeatureSelectorで指定したものと同じ名前にすること
}

/**
 * アプリ全体のStoreとReducerの関連付け
 */
export const reducers: ActionReducerMap<State> = {
  todo: fromTodo.reducer,
};

Effects

Actionを受け取って非同期処理を行う部分です。
処理した結果から新しくActionを発行することも出来ます。

effects/todo.effect.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { map, switchMap, catchError } from 'rxjs/operators';
import { Action } from '@ngrx/store';
import { Actions, Effect, toPayload } from '@ngrx/effects';

import * as TodoAction from '../actions/todo.action';
import { TodoService } from '../todo.service';

@Injectable()
export class TodoEffects {
  constructor(
    private actions$: Actions,
    private todoService: TodoService,
  ) {}

  /**
   * 作成
   */
  @Effect() create$: Observable<Action> = this.actions$
    .ofType(TodoAction.CREATE) // 対応するActionが発行されたら
    .pipe(
       map(toPayload),
       concatMap(payload =>
         this.todoService
           .create(payload)       // サービスのメソッドを実行した後
           .pipe(                 // 成功・失敗など次のActionを発行
              map(data => new TodoAction.CreateSuccess(data)),
              catchError(error =>of(new TodoAction.CreateFailure(error))
           )
    );
}

※アクションが「一覧読み込み」などの場合はswitchMap、それ以外はconcatMap/mergeMapを使うのが良いとされています(https://blog.angularindepth.com/switchmap-bugs-b6de69155524)。

サービスは以下のようなものを想定しています。

todo.service.ts(一部抜粋)
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

import { Todo } from './todo';

@Injectable()
export class TodoService {
  constructor(private http: HttpClient) { }
  create(todo: Todo): Observable<Todo> {
    const url = 'APIのアクセス先URL';
    return this.http.post<Todo>(url, todo);
  }
}

EffectもReducerと同様にまとめておくと良いと思います。こちらは配列に入れます。

effects/index.ts
import { TodoEffects } from './todo.effect';

export const effects = [
  TodoEffects
];

AppModuleに登録

StoreModuleEffectsModuleをアプリのモジュールにインポートしましょう。

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

import { effects } from './effects';
import { reducers } from './reducers';
import { TodoService } from './todo.service';
import { AppComponent } from './app.component';
import { Page1Component } from './page1/page1.component';

@NgModule({
  declarations: [
    AppComponent,
    Page1Component
  ],
  entryComponents: [
    Page1Component
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
    StoreModule.forRoot(reducers),  // Store用
    EffectsModule.forRoot(effects)  // Effects用
  ],
  providers: [TodoService],
  bootstrap: [AppComponent],
})
export class AppModule { }

これでAngularでReduxのような状態管理の仕組みが整いました。
早速コンポーネント内で使ってみましょう。

コンポーネント内で使う

作成したActionとReducerをインポートしましょう。
また、コンポーネントにStoreをDIしましょう。

page1/page1.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';

import * as TodoAction from '../actions/todo.action';
import * as TodoReducer from '../reducers/todo.reducer';
import { Todo } from '../todo.ts';

@Component({
  selector: 'app-page1',
  templateUrl: './page1.component.html',
  styleUrls: ['./page1.component.scss']
})
export class Page1Component implements OnInit {

  constructor(private store: Store<TodoReducer.State>) { } // StoreをDI

  ngOnInit() {
  }

}

Storeに格納された状態を取得するにはselect()を使用します。
Reducerを作るときに書いたセレクタを引数に指定しましょう。

page1/page1.component.ts
import { Observable } from 'rxjs/Observable';

export class Page1Component implements OnInit {
  loading$: Observable<boolean>;

  constructor(private store: Store<TodoReducer.State>) { } // StoreをDI

  ngOnInit() {
    this.loading$ = this.store.select(TodoReducer.getLoading); // 状態取得
  }
}

Actionを呼ぶ(ディスパッチ)には以下のように書きます。

page1/page1.component.ts
export class Page1Component implements OnInit {
  ...

  /**
   * 作成
   */
  create(todo: Todo) {
    this.store.dispatch(new TodoAction.Create(todo));
  }
}

ディスパッチだけでは成功/失敗がわからないので、@ngrx/effectsActionsを使ってActionを拾います。

これで「登録成功したらダイアログを閉じる」などを行うことができますね!

page1/page1.component.ts
import { Actions } from '@ngrx/effects';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
import { takeUntil } from 'rxjs/operators';

export class Page1Component implements OnInit, OnDestroy {

  onDestroy = new Subject();

  constructor(
    private store: Store<TodoReducer.State>,
    private actions$: Actions                // ActionsをDI
  ) { } 

  ...

  ngOnInit {
    this.actions$
      .ofType(TodoAction.CREATE_SUCCESS)
      .pipe(takeUntil(this.onDestroy))
      .subscribe(action => {
        // 成功時の処理
      });

    this.actions$
      .ofType(TodoAction.CREATE_FAILURE)
      .pipe(takeUntil(this.onDestroy))
      .subscribe(action => {
        // エラー時の処理
      });
  }

  ngOnDestroy {
    this.onDestroy.next();
  }

成功・失敗時の処理をngOnInit()内に書きたくない場合は↓のように書くこともできます。

page1/page1.component.ts
import { Observable } from 'rxjs/Observable';
import { race } from 'rxjs/observable/race';
import { tap, take } from 'rxjs/operators';
...
export class Page1Component implements OnInit {

  ...

  /**
   * 作成
   */
  create(todo: Todo) {
    this.store.dispatch(new TodoAction.Create(todo));

    // 成功時
    const success = this.actions$
      .ofType(TodoAction.CREATE_SUCCESS)
      .pipe(
        tap(()=> {
          // 成功時の処理(ページを戻る、モーダルを閉じるなど)
        })
      );

    // 失敗時
    const failure = this.actions$
      .ofType(TodoAction.CREATE_FAILURE)
      .pipe(
        tap(()=> {
          // エラー時の処理(エラーダイアログ表示など)
        })
      );

    // 成功・失敗どちらかを次へ流す(take(1)しているのでunsubscribe不要)
   race(success, failure).pipe(take(1)).subscribe();
  }
}

create()が連続で呼ばれると、その分だけtap()内の処理が実行されてしまうのであまりオススメしませんが...

終わりに

いかがだったでしょうか?

ngrxでReduxライクな状態管理を採用するとコードの量自体は増えますが、

  • Angularと親和性が高い&型があるので安心
  • コンポーネントと状態を分離できるのでスケールしやすい
  • 一つ一つはシンプルに書けるのでテストを書きやすい

という利点があるので、大規模なアプリケーションを作る際は非常に役立つと思います。

今回のサンプルは以下からダウンロードできます。
https://github.com/puku0x/ngrx-todo/tree/v0.1

↓最新のv5.x系に対応したものはこちらです (デモ + テストコード有)
https://github.com/puku0x/ngrx-todo

↓モバイルアプリに適用した例はこちらです
https://github.com/puku0x/ngx-onsenui-ngrx-todo

NgRxはまだ日本語の情報が少ないので「もっと良い使い方があるぞ!」という場合はコメントしていただければ幸いです。

参考

@ngrx example application
Using NgRx 4 to Manage State in Angular Applications
Comprehensive Introduction to @ngrx/store
Is NGRX Store compatible with Resolve to ensure data is loaded before a route is activated