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

**:tada: NgRx v9に対応しました :tada:**

[NgRx](https://github.com/ngrx/platform)は[Angular](https://angular.io/)アプリケーション用の状態管理ライブラリです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/106886/c685de94-57b3-8b5e-aa42-5a2d6d5a0a17.png)
本記事では、簡単なサンプルを通して[@ngrx/store](https://github.com/ngrx/store)と[@ngrx/effects](https://github.com/ngrx/effects)の使い方を紹介します。
※ サンプルはNgRx v9系で書かれています

# この記事の対象者

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

# TL;DR

NgRxはいいぞ。
[https://stackblitz.com/edit/ngrx-todo-v9](https://stackblitz.com/edit/ngrx-todo-v9?file=src/app/app.component.ts)
<img width="480" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/106886/2d5bd483-f033-d142-90b3-f87dabf1a52c.png">

## NgRxの概要

NgRxはAngular用の状態管理ライブラリです。NgRxを利用するとAngularを利用したアプリケーションでReduxのような状態管理ができます。

NgRxは下図のような構成になっており、Reduxと非常によく似ています。
><img width="480" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106886/473102d3-d353-b173-f54f-cc2c99e4949b.png">
https://ngrx.io/guide/store より

NgRxはRxJSベースで作られているため状態をストリームとして扱うことができます。

### @ngrx/store
Angular用の状態管理ライブラリです。
ReduxのようにAction、Reducer、Storeの概念があります。

@ngrx/storeのコンセプトは以下のように紹介されています。

> - Actionはコンポーネントやサービスからディスパッチされる一意のイベントを表す
> - 状態はReducer（現在の状態と最新のActionから新しい状態を返す純粋関数）によって更新される
> - Selectorは状態の一部を取り出したり組み合わせたりする純粋関数である
> - 状態はStateとActionのObservableである`Store`からアクセスされる
>
> https://ngrx.io/guide/store より翻訳

### @ngrx/effects
受け取ったActionを元にHTTP通信や別のActionを実行するなどの「副作用」を処理するライブラリ（redux-sagaやredux-thunkのようなもの）です。

@ngrx/effectsのコンセプトは以下のように紹介されています。

> - Effectsはコンポーネントから副作用を分離し、コンポーネントをより綺麗にする
> - EffectsはStoreにディスパッチされる全てのActionを監視し長期的に実行されるサービスである
> - Effectsは`ofType`オペレータによって関心のあるActionを絞り込む
> - Effectsは新しいActionを返す同期または非同期なタスクを処理する
>
> https://ngrx.io/guide/effects より翻訳

# NgRxを使ってみよう

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

## インストール

npmからインストールしてください。

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

## ディレクトリ構成

NgRxを用いる場合は、下図のようにフィーチャ毎に`store`ディレクトリを用意し必要なファイルをまとめていくと良いと思います。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/106886/83ad765e-534e-c75c-a656-b17f0c4b8829.png)

## State

まずは状態と初期状態を定義しましょう。

NgRxでは、

- Root state
ルートの状態、アプリケーション全体を通して利用される

- Feature state
各機能単位の状態、フィーチャモジュール内で利用される

と分けて扱うことが多いかと思います。

```ts:/src/app/store/state/app.state.ts
export interface State {
  // ルートの状態
}
```

```ts:/src/app/todo/store/state/todo.state.ts
export const featureName = 'todo';

export interface State {
  loading: boolean;
  todos: Todo[];
  error?: any;
}

export const initialState: State = {
  loading: false,
  todos: []
};
```

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

```ts:/src/app/models/todo.model.ts
export interface Todo {
  id: string;
  text: string;
}
```

今回の例では、アプリケーション全体の状態は以下のようになります。

```js:アプリケーション全体の状態
{
  todo: {
    loading: false,
    todos: [
      { id: '1', text: 'Learn Angular' },
      { id: '2', text: 'Learn RxJS' },
      { id: '3', text: 'Learn NgRx' },
    ],
    error: {...}
  }
}
```

## Selector

Selectorは状態の一部を切り出すものです。

`@ngrx/store`の[createFeatureSelector](https://ngrx.io/api/store/createFeatureSelector)を使ってFeature stateを取得した後、[createSelector](https://ngrx.io/api/store/createSelector)を使ってSelectorを作成します。

```ts:/src/app/todo/store/selectors/todo.selector.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';

import { State, featureName } from '../state';

const getState = createFeatureSelector<State>(featureName);
export const getLoading = createSelector(getState, state => state.loading);
export const getTodos = createSelector(getState, state => state.todos);
```

[createSelector](https://ngrx.io/api/store/createSelector)で作成したSelectorは自動的にメモ化されるため高速にアクセスできます。

## Action
`@ngrx/store`の[createAction](https://ngrx.io/api/store/createAction)を使ってActionを作成します。

第1引数にはActionの名前を指定します。Actionの名前は[Good Action Hygiene](https://www.youtube.com/watch?v=JmnsEvoy-gY)を参考に`[Source] Event`の形式にすると良いと思います。

第2引数にはActionのペイロードを指定します。`@ngrx/store`が提供する[props](https://ngrx.io/api/store/props)を使うと簡潔に書けます。

```ts:/src/app/todo/store/actions/todo.action.ts
import { createAction, props } from '@ngrx/store';

export const loadAll = createAction(
  '[Todo Page] Load All',
  props<{ offset?: number; limit?: number }>()
);

export const loadAllSuccess = createAction(
  '[Todo API] Load All Success',
  props<{ todos: Todo[] }>()
);

export const loadAllFailure = createAction(
  '[Todo API] Load All Failure',
  props<{ error: any }>()
);
```

ちなみに↓のように書くと、

```ts:/src/app/todo/store/actions/todo.action.ts
export const createSuccess = createAction(
  '[Todo API] Load All Success',
  (payload: { todos: Todo[] }) => ({ payload })
);
```

`action.payload`の形でペイロードを取り出すことができます。

## Effects

Actionを受け取り、副作用を処理する部分です。

```ts:/src/app/todo/store/effects/todo.effect.ts
import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect, act } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, tap, concatMap, switchMap, catchError } from 'rxjs/operators';

import { TodoService } from '../../services';
import * as TodoActions from '../actions';

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

  loadAll$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.loadAll),
      switchMap(({ offset, limit }) =>
        this.todoService.loadAll(offset, limit).pipe(
          map(result => TodoActions.loadAllSuccess({ todos: result })),
          catchError(error => of(TodoActions.loadAllFailure({ error })))
        )
      )
    )
  );
}
```

マッピングオペレータについては、一覧読み込みなどの場合は`switchMap`、それ以外は`concatMap`を使うのをお勧めします（[参考URL](https://blog.angularindepth.com/switchmap-bugs-b6de69155524)）。

`TodoService`は以下のようなものを想定しています。

```ts:/src/app/todo/services/todo.service.ts（一部抜粋）
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

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

@Injectable({
  providedIn: 'root'
})
export class TodoService {
  constructor(private http: HttpClient) {}

  findAll(offset?: number, limit?: number) {
    const url = 'APIのアクセス先URL';
    let params = new HttpParams();
    params = offset ? params.set('offset', `${offset}`) : params;
    params = limit ? params.set('limit', `${limit}`) : params;
    return this.http.get<Todo[]>(url, { params });
  }
}
```

新しいアクションを呼ばない場合は`{ dispatch: false }`を渡しましょう。

```ts:/src/app/todo/store/effects/todo.effect.ts
@Injectable()
export class TodoEffects {
  createSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(TodoActions.loadAllSuccess),
        tap(action => {
          console.log('Success!');
        })
      ),
    { dispatch: false }
  );
}
```

## Reducer

`@ngrx/store`の[createReducer](https://ngrx.io/api/store/createReducer)を使ってReducerを作成します。

Reducerでは各Actionに対して状態がどのように遷移するかを[on](https://ngrx.io/api/store/on)を使って書いていきます。

```ts:/src/app/todo/store/reducers/todo.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';

import { State, initialState } from '../state';
import * as TodoActions from '../actions';

export const reducer = createReducer(
  initialState,
  on(TodoActions.loadAll, state => {
    return { ...state, loading: true };
  }),
  on(TodoActions.loadAllSuccess, (state, { todos }) => {
    return { ...state, loading: false, todos: [...state.todos, ...todos] });
  }),
  on(TodoActions.loadAllFailure, (state, { error }) => {
    return { ...state, loading: false, error };
  }),
);
```

本記事では解説しませんが、このようなエンティティのCRUD操作を簡略化する[@ngrx/entity](https://ngrx.io/guide/entity)というパッケージもあります。

## Facade

FacadeはNgRxの処理を抽象化します。作成は任意ですが、Facadeのような中間層を設けることでコンポーネントがNgRxに強く依存することを防ぎ、将来的なライブラリの変更にも柔軟に対応できるようになると思います（[参考URL](https://medium.com/@thomasburlesonIA/ngrx-facades-better-state-management-82a04b9a1e39)）。

```ts:/src/app/todo/store/todo.facade.ts
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';

import { Todo } from '../../models';
import * as TodoSelectors from './selectors';
import * as TodoActions from './actions';
import { TodoStoreModule } from './todo-store.module';

@Injectable({
  providedIn: TodoStoreModule  // 'root' でもOK
})
export class TodoFacade {
  loading$ = this.store.pipe(select(TodoSelectors.getLoading));
  todos$ = this.store.pipe(select(TodoSelectors.getTodos));

  constructor(private store: Store) {}

  loadAll(offset?: number, limit?: number) {
    this.store.dispatch(TodoActions.loadAll({ offset, limit }));
  }
}
```

## モジュールの作成

NgRxでは[StoreModule.forFeature()](https://ngrx.io/api/store/StoreModule)を用いてRoot stateにFeature stateを追加できます。
<img width="640" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/106886/e6b4703d-3ceb-1fdd-da13-d27405d03dfc.png">

StoreModuleの初期化は`AppModule`内、またはストア用モジュールで行うと良いでしょう。

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

import { reducers, metaReducers } from './reducers';

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

`metaReducers`にはログ等の前処理を差し込むことができます。追加は任意です。

デフォルトでは、開発時に状態やActionのイミュータブルチェックが行われますが、`runtimeChecks`を指定して設定を上書きすることもできます。この他にもActionがシリアライズ可能かどうかのチェックもあるので必要に応じて切り替えると良いでしょう。

```ts
StoreModule.forRoot(reducers, {
  metaReducers,
  runtimeChecks: {
    strictStateSerializability: false,  // デフォルト: false
    strictActionSerializability: false, // デフォルト: false
    strictActionImmutability: false,    // デフォルト: true
    strictStateImmutability: false,     // デフォルト: true
    strictActionWithinNgZone: false     // デフォルト: false
  }
})
```

続いてFeature state用のモジュールを作成し、ReducerとEffectsを登録します。

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

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

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

作成した`TodoStoreModule`は`TodoModule`にインポートしましょう。

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

import { TodoStoreModule } from './store';
import { TodoComponent } from './todo.component';

@NgModule({
  imports: [
    CommonModule,
    TodoStoreModule
  ],
  exports: [TodoComponent],
  declarations: [TodoComponent]
})
export class TodoModule {}
```

「状態をNgModuleとして扱える」と理解することがNgRxで状態を設計するコツです👍

## AppModuleに登録

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

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

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

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    AppStoreModule,  // 必須
    TodoModule,      // ←Lazy Loadingしても良い
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: []
})
export class AppModule {}
```

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

## コンポーネント内で使う

`Store`をコンポーネントにDIしましょう。

```ts:/src/app/app.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';

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

状態の取得やActionのディスパッチを行います。

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

import * as TodoSelectors from './store/todo/selectors';
import * as TodoActions from './store/todo/actions';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
  this.todos$ = this.store.pipe(select(TodoSelectors.getTodos));

  constructor(private todoService: TodoFacade) {}

  ngOnInit() {
    const offset = 0;
    const limit = 10;
    this.store.dispatch(TodoActions.loadAll({ offset, limit }));
  }
}
```

テンプレート内で`async`パイプを使うと状態を簡単に取り出せます。

```html:/src/app/app.component.html
<ul>
  <li *ngFor="let todo of todos$ | async">
    {todo.text}
  </li>
</ul>
```


### Facadeを使う場合
作成した`TodoFacade`をDIしましょう。

```ts:/src/app/app.component.ts
import { Component } from '@angular/core';

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

状態の取得やActionのディスパッチはFacade経由で行います。

```ts:/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';

import { TodoFacade } from './store';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
  this.todos$ = this.todoService.todos$;

  constructor(private todoService: TodoFacade) {}

  ngOnInit() {
    const offset = 0;
    const limit = 10;
    this.todoService.loadAll(offset, limit);
  }
}
```

# 終わりに
[NgRx](https://github.com/ngrx/platform)を利用することで、AngularアプリケーションにReduxのような状態管理を組み込むことができます。Angularとの親和性が高いだけでなく、優秀な型推論や豊富な機能、テストユーティリティ、ドキュメントも充実しているので安心して使用できると思います。

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

本記事を見て興味を持ったという方はぜひデモで動作確認してみてください。
[https://stackblitz.com/edit/ngrx-todo-v9](https://stackblitz.com/edit/ngrx-todo-v9?file=src/app/app.component.ts)
<img width="400" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/106886/2d5bd483-f033-d142-90b3-f87dabf1a52c.png">
テストコードを含む今回のサンプルのソースは以下からダウンロードできます。
https://github.com/puku0x/ngrx-todo

[NgRx](https://github.com/ngrx/platform)はまだ日本語の情報が少ないので「もっと良い使い方があるぞ！」という場合はコメントしていただければ幸いです。

# 参考
[NgRx document](https://ngrx.io)
[NgRx example application](https://github.com/ngrx/platform/tree/master/projects/example-app)
[Comprehensive Introduction to @ngrx/store](https://gist.github.com/btroncone/a6e4347326749f938510)
[NgRx + Facades: Better State Management](https://medium.com/@thomasburlesonIA/ngrx-facades-better-state-management-82a04b9a1e39)