angular
RxJS
redux
ngrx

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

:tada: NgRx v6に対応しました :tada:

NgRxAngularアプリケーション用の状態管理ライブラリです。
image.png
本記事では、簡単なサンプルを通して@ngrx/store@ngrx/effectsの使い方を紹介します。
※ サンプルはNgRx v6.x系で書かれています

記事の対象者

  • Angularを利用している方
  • Angular + RxJSで状態管理をしたい方
  • NgRxを触ってみたい方

TL;DR

NgRxはいいぞ。

デモ
https://stackblitz.com/edit/ngrx-todo-v1

NgRxとRedux

NgRxを利用するとReduxのような状態管理機構を組み込むことができます。
Reduxは下図のような単方向データフローを持っており、

下記の特徴があります。

  • Single source of truth(アプリ全体の状態は単一のオブジェクトツリー)
  • State is read-only(状態は読み取り専用)
  • Changes are made with pure functions(状態の変更は純粋関数によって行われる)

NgRxでは状態をストリームとして扱うことができます。

@ngrx/store

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

@ngrx/effects

受け取ったActionを元にWebAPIにアクセスするといった「副作用」を処理するライブラリ(redux-sagaやredux-thunkのようなもの)です。

NgRxを使ってみよう

さっそくNgRxを使ってみましょう。
今回は簡単なTodoアプリに組み込むことを想定します。

インストール

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

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

Action

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

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

/**
 * Actionの種類
 */
export enum TodoActionTypes {
  CreateTodo        = '[Todo] Create',
  CreateTodoSuccess = '[Todo] Create Success',
  CreateTodoFail    = '[Todo] Create Fail',
}

/**
 * 作成
 */
export class CreateTodo implements Action {
  readonly type = TodoActionTypes.CreateTodo;
  constructor(public payload: { todo: Todo } ) {}
}

/**
 * 作成成功
 */
export class CreateTodoSuccess implements Action {
  readonly type = TodoActionTypes.CreateTodoSuccess;
  constructor(public payload: { todo: Todo }) {}
}

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

export type TodoActions =
  | CreateTodo
  | CreateTodoSuccess
  | CreateTodoFail;

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

後述するReducerやEffectsでペイロード内を分割代入できるように、引数のpayloadには常に{}を付けることをお勧めします。

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

/src/app/models/todo.model.ts
export class Todo {
  constructor(
    public id?: string,
    public text?: string,
  ) {}
}

Reducer

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

/src/app/todo/store/reducers/todo.reducer.ts
import { Todo } from '../../../models';
import { TodoActionTypes, TodoActions } from '../actions';

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

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

/**
 * Reducer
 * @param state 現在の状態
 * @param action 発行されたAction
 */
export function reducer(state = initialState, action: TodoActions): State {
  switch (action.type) {
    case TodoActionTypes.CreateTodo: {
      // 作成
      return { ...state, loading: true };
    }
    case TodoActionTypes.CreateTodoSuccess: {
      // 作成成功したら一覧に追加
      const { todo } = action.payload;
      return { ...state, loading: false, todos: [...state.todos, todo] };
    }
    case TodoActionTypes.CreateTodoFail: {
      // 作成失敗
      return { ...state, loading: false };
    }
    default: {
      return state;
    }
  }
}

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

Reducerはバレル(index.ts)から呼ぶようにしましょう。
セレクタもまとめておくと良いと思います。

/src/app/todo/store/reducers/index.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';

import * as fromTodo from './todo.reducer';

/**
 * 状態 および Reducer
 */
export { State, reducer } from './todo.reducer';

/**
 * セレクタ
 */
export const getFeatureState = createFeatureSelector<fromTodo.State>('Todo');
export const getLoading      = createSelector(getFeatureState, fromTodo.getLoading);
export const getTodos        = createSelector(getFeatureState, fromTodo.getTodos);

createFeatureSelectorにはフィーチャの状態名と型を指定してください。

状態
{
  Todo: {
    todos: [
      { id: '1', text: 'Learn Angular' },
      { id: '2', text: 'Learn RxJS' },
      { id: '3', text: 'Learn NgRx' },
    ]
  }
}

今回の例では↑のTodoというフィーチャの状態へのアクセスを示しています。

Reducerが複数ある場合

Reducerが複数ある場合はActionReducerMapを用いてまとめましょう。

/src/app/todo/store/reducers/index.ts
import { ActionReducerMap, createFeatureSelector, createSelector } from '@ngrx/store';

import * as fromTodo from './todo.reducer';
import * as fromSearch from './search.reducer';

/**
 * 状態
 */
export interface State {
  todo: fromTodo.State;
  search: fromSearch.State;
}

/**
 * Reducerマップ
 * 状態とReducerを関連付ける
 */
export const reducers: ActionReducerMap<State> = {
  todo: fromTodo.reducer,
  search: fromSearch.reducer,
};

/**
 * セレクタ
 */
export const getFeatureState = createFeatureSelector<State>('Todo');

// todo用
export const getTodoState = createSelector(getFeatureState, state => state.todo);
export const getLoading   = createSelector(getTodoState, fromTodo.getLoading);
export const getTodos     = createSelector(getTodoState, fromTodo.getTodos);

// search用
export const getSearchState = createSelector(getFeatureState, state => state.search);

この場合、状態は下記のようになります。

状態
{
  Todo: {
    todo: {
      todos: [
        { id: '1', text: 'Learn Angular' },
        { id: '2', text: 'Learn RxJS' },
        { id: '3', text: 'Learn NgRx' },
      ]
    },
    search: {
    }
  }
}

※深い階層構造を持つ状態は避けた方が良いでしょう。

Effects

Actionを受け取り、非同期処理などの副作用を処理する部分です。
処理した結果から新しくActionを発行することが出来ます。

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

import { Todo } from '../../../models';
import { TodoService } from '../../../core';
import {
  TodoActionTypes,
  CreateTodo,
  CreateTodoSuccess,
  CreateTodoFail,
} from '../actions';

/**
 * Todo effects
 */
@Injectable()
export class TodoEffects {

  constructor(private actions$: Actions, private todoService: TodoService) { }

  /**
   * 作成
   */
  @Effect()
  createTodo$: Observable<Action> = this.actions$.pipe(
    ofType<CreateTodo>(TodoActionTypes.CreateTodo),
    map(action => action.payload),
    concatMap(payload => {
      const { todo } = payload;
      return this.todoService.create(todo).pipe(
        map(result => new CreateTodoSuccess({ todo: result })),
        catchError(error => of(new CreateTodoFail({ error })))
      )
    })
  );

  /**
   * 作成成功
   */
  @Effect({ dispatch: false })
  createSuccess$ = this.actions$.pipe(
    ofType<CreateSuccess>(StaffActionTypes.CreateSuccess),
    tap(action => {
      console.log('CreateSuccess');
    })
  );

  /**
   * 作成失敗
   */
  @Effect({ dispatch: false })
  createFail$ = this.actions$.pipe(
    ofType<CreateFail>(StaffActionTypes.CreateFail),
    tap(action => {
      console.log('CreateFail');
    })
  );
}

※「一覧読み込み」などの場合は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';

import { Todo } from '../../models';

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

モジュールの作成

状態用のモジュールを作成し、StoreModuleEffectsModuleをインポートしましょう。

/src/app/todo/store/todo-store.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

import { reducer } from './reducers';
import { TodoEffects } from './effects';

@NgModule({
  imports: [
    StoreModule.forFeature('Todo', reducer),
    EffectsModule.forFeature([TodoEffects])
  ]
})
export class TodoStoreModule { }

↑ここでフィーチャの状態名を揃えておかないとcreateFeatureSelectorがundefinedを返します。

NgRxを用いる場合は、下図のようにフィーチャ毎にstoreディレクトリを用意し必要なファイルをまとめていくと良いと思います。

image.png

状態用のモジュールはフィーチャモジュールにインポートしておきましょう。

/src/app/todo/todo.module.ts
import { NgModule } from '@angular/core';

import { TodoStoreModule } from './store';

@NgModule({
  imports: [
    TodoStoreModule
  ]
})
export class TodoModule { }

NgRxでは、StoreModule.forFeature()を用いて「ルートの状態(Root state)」に「フィーチャの状態(Feature state)」を足していくように状態を作ることができます。

ルートの状態はAppModuleまたはストア用モジュールに入れると良いでしょう。

/src/app/core/store/core-store.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

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

「状態をNgModuleとして扱える」と理解することで、あまり混乱することなく状態を設計できると思います。さらに、Lazy loadingと組み合わせてより効率的な設計を目指すのも良いでしょう。

AppModuleに登録

最後に、先程作成したモジュールをAppModuleにインポートしましょう。

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

import { AppStoreModule } from './app-store.module';
import { TodoModule } from './todo';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
    AppStoreModule,
    TodoModule,    // フィーチャは Lazy loading にしても良い
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: []
})
export class AppModule { }

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

コンポーネント内で使う

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

/src/app/app.component.ts
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';

import { Todo } from './models';
import { CreateTodo } from './todo/store/actions';
import * as fromTodo from './todo/store/reducers';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent {

  loading$: Observable<boolean>;
  todos$: Observable<Todo[]>;

  constructor(private store: Store<fromTodo.State>) {       // StoreをDI
    this.loading$ = this.store.pipe(select(fromTodo.getLoading));
    this.todos$ = this.store.pipe(select(fromTodo.getTodos));
  }
}

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

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

/src/app/app.component.ts
export class AppComponent {
  ...

  /**
   * 作成
   */
  create(text: string) {
    const todo = new Todo(null, text);
    this.store.dispatch(new CreateTodo({ todo }));
  }
}

成功/失敗時の動作をコンポーネントに追加する

アクションの成功または失敗に対する動作は通常Effectsで処理します。コンポーネント内で成功/失敗を取得したい場合は @ngrx/effectsActionsをDIしてActionのストリームを拾います。「登録成功したらダイアログを閉じる」を実装する場合は下記のように書いても良いです。

/src/app/app.component.ts
import { Component, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { Store } from '@ngrx/store';
import { Actions, ofType } from '@ngrx/effects';
import { Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { Todo } from './models';
import {
  TodoActionTypes,
  CreateTodo
} from './todo/store/actions';
import * as fromTodo from './todo/store/reducers';

export class AppComponent implements OnInit, OnDestroy {
  private readonly onDestroy$ = new EventEmitter();

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

  ...

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

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

  ngOnDestroy {
    this.onDestroy$.emit();
  }

終わりに

NgRxを利用することで、AngularアプリケーションにReduxのような状態管理機構を組み込むことができます。Angularとの親和性が高いことに加え、型が使えるので安心して使用できると思います。また、ActionやReducerは単純なクラスや関数なのでテストも書きやすいです。

「アプリケーションの規模が大きくなって状態管理つらい...:sweat:」となったときにNgRxはきっと役に立つと思います。

本記事を見て興味を持ったという方はぜひデモで動作を確認してみてください。
https://stackblitz.com/edit/ngrx-todo-v1

テストコードを含む今回のサンプルのソースは以下からダウンロードできます。
https://github.com/puku0x/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