Angularのngrxを使って状態管理を行う（実装編：初期設定～エフェクト設定）

> この記事は[Angular+Firebaseでチャットアプリを作る](http://qiita.com/Yamamoto0525/items/a76ea4b3924eeb82b0f9)のエントリーです。
> 前記事：[Angularのngrxを使って状態管理を行う（理論編）](https://qiita.com/Yamamoto0525/items/03155acbce1ffd1d037e)
> 次記事：[Angularのngrxを使って状態管理を行う（実装編：エンティティ設定）](https://qiita.com/Yamamoto0525/items/e66804ba742bceeb3e38)

# この記事で行うこと

[前回の記事](https://qiita.com/Yamamoto0525/items/03155acbce1ffd1d037e)ではngrxの概念と基本的な構成を扱いました。
本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。

- [ngrxの初期設定](#ngrxの初期設定)
- [Action、Reducerの実装](#actionreducerの実装)
- [EffectでFirebaseのデータをストアに反映](#effectでfirebaseのデータをストアに反映)
- [View（コンポーネント）にStateを反映](#viewコンポーネントにstateを反映)

*****
（追記：2020/6）現時点（2020年6月）での最新の内容に書き換えています。
*****

# 実装内容

## ngrxの初期設定

### ライブラリインストール

まず、コードジェネレーターである`@ngrx/schematics`を開発環境に、それ以外のライブラリをプロジェクトにインストールします。

```bash
ng add @ngrx/schematics
? Do you want to use @ngrx/schematics as the default collection? y
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools --save
```
> (注)
> AngularとFirebaseの環境構築は[過去の記事](https://qiita.com/Yamamoto0525/items/a76ea4b3924eeb82b0f9)から行ってください。
> また、この実装はAngular6以上であることが必要ですので注意してください。
> (2020/06追記)
> NgRx v7.4から Action Creator機能が追加され、記述の省略化ができるようになりました。本記事でもその機能を使って、コードを全面的に修正しています。
> 参考: [NgRx v7.4で導入されるAction Creatorの使い方](https://lacolaco.hatenablog.com/entry/2019/04/03/223347)


#### `@ngrx/schematics`ジェネレータのコマンド一覧

|コマンド|概要|
|:-----------------|:------------------|
|`ng g action ActionName [options]`|Actionファイルの作成|
|`ng g effect EffectName [options]`|Effectファイルの作成|
|`ng g reducer ReducerName [options]`|Reducerファイルの作成|
|`ng g entity EntityName [options]`|Entityを扱う場合のファイル群を作成|
|`ng g container ComponentName [options]`|コンポーネント（html\css\spec\ts）の作成|
|`ng g feature FeatureName [options]`|機能ストアの初期設定、Actionファイル等の同時作成|
|`ng g store State [options]`|ストア（ルート、機能）、Stateの初期設定|

**参考**
[@ngrx/schematics](https://ngrx.io/guide/schematics)

### ステートツリー設計

アプリーケーション全体で利用するストア（ルートストア）のステートツリーは、次のような構成にします。

```bash
state
　└── session
　    　├── session（セレクタ）
    　　└── loading（セレクタ）
```

ルートストアはアプリケーションが読み込まれると同時に読み込みを開始するので、コアモジュールに登録します。
また、ここで扱うステートにはセッションなどアプリケーション全体で利用するものが適しているので、今回はセッションとローディングを設定します。

それでは早速、Coreモジュールにストアとエフェクトを作成します。

```bash
// ルートストアの追加
ng g module app-store --module app.module.ts
ng g store State --root --statePath app-store/reducers --module app-store/app-store.module.ts
// ルートエフェクトの追加
ng g effect app-store/effects/Session --root --module app-store/app-store.module.ts
? Should we wire up success and failure actions?  Yes
? Do you want to use the create function?  Yes 
```

**--root**
ルートストアの設定を行います。この指定がない場合、自動的に機能ストア（別記事で詳述）が作成されます。

**--statePath**
ストアのパスを指定します。※ストア以外は`ng g effect パス`のように指定します。

**--module**
対象としたいモジュールを指定します。

`@ngrx/store-devtools`が動作するよう`reducers/index.ts`にaction、stateのログ機能を実装しておきます。

```ts:app/app-store/reducers/index.ts
export function logger(reducer: ActionReducer<State>) { // 追加
  return (state, action) => {
    const newState = reducer(state, action);
    console.log('action', action);
    console.log('state', newState);
    return newState;
  };
}

export const metaReducers: MetaReducer<State>[] = !environment.production ? [logger] : []; // 変更
```
#### `@ngrx/store-devtools`

`@ngrx/store-devtools`はChromeの拡張ツールを使って視覚的にステートツリーの状況確認を行うための開発用ツールです。[拡張ツール](https://github.com/zalmoxisus/redux-devtools-extension/)をダウンロードした後、Chromeの開発者ツールから使用することができます。

どのような挙動をするかは[実行結果](#実行結果)で確認してください。

次に、Action、Reducerを追加します。

```bash
// Actionの追加
ng g action app-store/actions/Session
? Should we generate success and failure actions? Yes
? Do you want to use the create function? Yes
// Reducerの追加
ng g reducer app-store/reducers/Session --reducers index.ts
? Should we add success and failure actions to the reducer? Yes
? Do you want to use the create function? Yes
```

**--reducers**
reducerを加えたいストアを指定します（相対パス）。

これで必要なファイルは整いました。

## Action、Reducerの実装

それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。

|Action名|起動タイミング|役割|
|:-----------------|:------------------|:------------------|
|loadSessions|サイト訪問時|ログイン有無の問合せ|
|loadSessionsSuccess|loadSessionsが成功|ユーザーデータの取得|
|loadSessionsFailure|loadSessionsが失敗|-|
|loginSessions|ログインクリック時|ログイン有無の問合せ|
|loginSessionsSuccess|loginSessionsが成功|ユーザーデータの取得|
|loginSessionsFailure|loginSessionsが失敗|-|
|logoutSessions|ログアウトクリック時|ログインの無効化|
|logoutSessionsSuccess|logoutSessionsが成功|ユーザーデータの破棄|
|logoutSessionsFailure|logoutSessionsが失敗|-|

ここではLoad、Login、Logoutという３つの動作と、それぞれにSuccess、Failureという非同期の結果（Side Effect）を加えた計9種類のActionを定義しました。

それぞれのActionに対し、クラスとタイプ、初期値を加えていきます。

```ts:app/app-store/actions/session.actions.ts
import { createAction, props, union } from '@ngrx/store';
import { Session } from '../../class/chat';

export const loadSessions = createAction(
  '[Session] Load Sessions'
);

export const loadSessionsSuccess = createAction(
  '[Session] Load Sessions Success',
  (payload: { session: Session }) => ({ payload }),
);

export const loadSessionsFailure = createAction(
  '[Session] Load Sessions Failure',
  (payload: { error: any }) => ({ payload }),
);

export const loginSessions = createAction(
  '[Session] Login Sessions',
  (payload: { email: string, password: string }) => ({ payload }),
);

export const loginSessionsSuccess = createAction(
  '[Session] Login Sessions Success',
  (payload: { session: Session }) => ({ payload }),
);

export const loginSessionsFailure = createAction(
  '[Session] Login Sessions Failure',
  (payload: { error: any }) => ({ payload }),
);

export const logoutSessions = createAction(
  '[Session] Logout Sessions'
);

export const logoutSessionsSuccess = createAction(
  '[Session] Logout Sessions Success',
  (payload: { session: Session }) => ({ payload }),
);

export const logoutSessionsFailure = createAction(
  '[Session] Logout Sessions Failure',
  (payload: { error: any }) => ({ payload }),
);

const actions = union({
  loadSessions,
  loadSessionsSuccess,
  loadSessionsFailure,
  loginSessions,
  loginSessionsSuccess,
  loginSessionsFailure,
  logoutSessions,
  logoutSessionsSuccess,
  logoutSessionsFailure,
});

export type SessionUnionActions = typeof actions;
```

続いてReducerの実装に入ります。`session.reducer.ts`のState、initialStateに値を入力します。このとき、ローディングの状態も管理できるよう「loading」も追加しておきます。

```ts:app/app-store/reducers/session.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { Session } from '../../class/chat';
import * as SessionActions from '../actions/session.actions';

export const sessionFeatureKey = 'session';

export interface State {
  loading: boolean;
  session: Session;
}

export const initialState: State = {
  loading: false,
  session: new Session(),
};
```
Stateの設定が完了したら、reducerにアクション毎の処理を加えます。
非同期処理のトリガーとなるActionには`loading: true`を加え、その結果が更新され次第`loading: false`にstateが上書きされるようにしておきます。

```ts:app/app-store/reducers/session.reducer.ts
export const reducer = createReducer(
  initialState,
  on(SessionActions.loadSessions, state => ({ ...state, loading: true })),
  on(SessionActions.loadSessionsSuccess, (state, action) => ({ ...state, loading: false, session: action.payload.session })),
  on(SessionActions.loadSessionsFailure, state => ({ ...state, loading: false })),
  on(SessionActions.loginSessions, state => ({ ...state, loading: true })),
  on(SessionActions.loginSessionsSuccess, (state, action) => ({ ...state, loading: false, session: action.payload.session })),
  on(SessionActions.loginSessionsFailure, state => ({ ...state, loading: false })),
  on(SessionActions.logoutSessions, state => ({ ...state, loading: true })),
  on(SessionActions.logoutSessionsSuccess, () => initialState),
  on(SessionActions.logoutSessionsFailure, state => ({ ...state, loading: false })),
);

```

最後に特定のstateを取得できるようセレクタの設定を行います。
どのreducerのstateなのかをセレクタで指定し、View（コンポーネント）はそのセレクタを指定することでstateの取得を行います。

今回の実装であれば、まず`app-store/reducers/session.reducer.ts`でstateを取得するためのメソッドを作成し、`app-store/reducers/index.reducer.ts`で`createSelector()`を使ってセレクタを作成します。

```ts:app/app-store/reducers/session.reducer.ts
export const getSessionLoading = (state: State) => state.loading;
export const getSessionData = (state: State) => state.session;
```

```ts:app/app-store/reducers/index.reducer.ts
export const selectSession = (state: State) => state.session;
export const getLoading = createSelector(selectSession, fromSession.getSessionLoading);
export const getSession = createSelector(selectSession, fromSession.getSessionData);
```

これでAction、Reducerの設定は完了しました。
次はEffectの設定を行います。

## EffectでFirebaseのデータをストアに反映

まずはEffectにサイト訪問時のログイン状況確認用メソッドを加え、Sessionクラスのコンストラクタを更新します。

```ts:app/app-store/effects/session.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Session, User } from '../../class/chat';
import { User as fbUser } from 'firebase';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';
import { Router } from '@angular/router';
import * as SessionActions from '../actions/session.actions';
import { Observable, of } from 'rxjs';
import { switchMap, take, map, catchError } from 'rxjs/operators';


@Injectable()
export class SessionEffects {

  constructor(private actions$: Actions<SessionActions.SessionUnionActions>,
              private afAuth: AngularFireAuth,
              private afs: AngularFirestore,
              private router: Router) {
  }

  loadSession$ = createEffect(() =>
    this.actions$.pipe(
      ofType(SessionActions.loadSessions),
      switchMap(() => {
        return this.afAuth.authState
          .pipe(
            take(1),
            map((result: fbUser | null) => {
              if (!result) {
                // ユーザーが存在しなかった場合は、空のセッションを返す
                return SessionActions.loadSessionsSuccess({ session: new Session() });
              } else {
                return result;
              }
            }),
            catchError(this.handleLoginError(
              'fetchAuth', SessionActions.loadSessionsFailure)
            )
          );
      }),
      // ユーザーの認証下情報を取得
      switchMap((auth: fbUser | any) => {
        // ユーザーが存在しなかった場合は、認証下情報を取得しない
        if (!auth.uid) {
          return of(auth);
        }
        return this.afs
          .collection<User>('users')
          .doc(auth.uid)
          .valueChanges()
          .pipe(
            take(1),
            map((result: User) => {
              return SessionActions.loadSessionsSuccess({
                session: new Session(result)
              });
            }),
            catchError(this.handleLoginError(
              'fetchUser', SessionActions.loadSessionsFailure)
            )
          );
      })
    )
  );

  // エラー発生時の処理
  private handleLoginError(operation = 'operation', result: any) { // 変更
    return (error: any): Observable<any> => {

      // 失敗した操作の名前、エラーログをconsoleに出力
      console.error(`${operation} failed: ${error.message}`);

      // ログアウト処理
      this.afAuth.signOut()
        .then(() => this.router.navigate(['/account/login']));

      // 結果を返して、アプリを持続可能にする
      return of(result);
    };
  }
}
```

```ts:class/chat.ts
export class Session {
  login: boolean;
  user: User;

  constructor(init?: User) { // 変更
    this.login = (!!init);
    this.user = (init) ? new User(init.uid, init.name) : new User();
  }
```

~~Effectのメソッドには、`@Effect()`デコレータを加えます。
これにより、`ofType()`オペレータで指定したActionをdispatchした段階で該当メソッドを発火させることができます。~~

> (2020/06追記)
> `createEffect()`メソッドを使うことによって、`@Effect()`デコレータを使わなくともエフェクトの設定が可能になりました。

その後、Firebaseに認証状況を問合せ、その結果によって認証下情報(users)の問合せに移行します。
これらの問合せが成功した場合には`loadSessionsSuccess()`、失敗した場合には`loadSessionsFailure()`のActionを発生させ、その結果をStateに反映させます。

エラー発生時には`catchError()`オペレータを使用し、`handleLoginError()`メソッドに渡しています。
エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。

同様にログインクリック時、ログアウト時のエフェクトを追加します。

```ts:app/app-store/effects/session.effects.ts
  loginSession$ = createEffect(() =>
    this.actions$.pipe(
      ofType(SessionActions.loginSessions),
      map(action => action.payload),
      switchMap((payload: { email: string, password: string }) => {
        return this.afAuth
          .signInWithEmailAndPassword(payload.email, payload.password)
          .then(auth => {
            // ユーザーが存在しなかった場合は、空のセッションを返す
            if (!auth.user.emailVerified) {
              alert('メールアドレスが確認できていません。');
              this.afAuth.signOut()
                .then(() => this.router.navigate(['/account/login']));
              return SessionActions.loginSessionsSuccess({ session: new Session() });
            } else {
              return auth.user;
            }
          })
          .catch(err => {
              alert('ログインに失敗しました。\n' + err);
              return SessionActions.loginSessionsFailure({ error: err });
            }
          );
      }),
      switchMap((auth: fbUser | any) => {
        // ユーザーが存在しなかった場合は、空のセッションを返す
        if (!auth.uid) {
          return of(auth);
        }
        return this.afs
          .collection<User>('users')
          .doc(auth.uid)
          .valueChanges()
          .pipe(
            take(1),
            map((result: User) => {
              alert('ログインしました。');
              this.router.navigate(['/']);
              return SessionActions.loginSessionsSuccess({
                session: new Session(result)
              });
            }),
            catchError(this.handleLoginError(
              'loginUser', SessionActions.loginSessionsFailure, 'login'
            ))
          );
      })
    )
  );

  logoutSession$ = createEffect(() =>
    this.actions$.pipe(
      ofType(SessionActions.logoutSessions),
      switchMap(() => this.afAuth.signOut()),
      switchMap(() => {
        return this.router.navigate(['/account/login'])
          .then(() => {
            alert('ログアウトしました。');
            return SessionActions.logoutSessionsSuccess({
              session: new Session()
            });
          });
      }),
      catchError(this.handleLoginError(
        'logoutUser', SessionActions.logoutSessionsFailure, 'logout'
      ))
    )
  );

  // エラー発生時の処理
  private handleLoginError(operation = 'operation', result: any, dialog?: 'login' | 'logout') { // 変更
    return (error: any): Observable<any> => {

      // 失敗した操作の名前、エラーログをconsoleに出力
      console.error(`${operation} failed: ${error.message}`);

      // アラートダイアログの表示 // 追加
      if (dialog === 'login') {
        alert('ログインに失敗しました。\n' + error);
      }

      if (dialog === 'logout') {
        alert('ログアウトに失敗しました。\n' + error);
      }

      // ログアウト処理
      this.afAuth.signOut()
        .then(() => this.router.navigate(['/account/login']));

      // 結果を返して、アプリを持続可能にする
      return of(result);
    };
  }
```

これでエフェクトの実装は完了です。
これにより、`sessionState`からデータを取得する処理が不要になったので、`session.service.ts`内の記述を変更します。

```ts:app/app-service/session.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; // 変更
import { map, switchMap, take } from 'rxjs/operators';
// import { User as fbUser } from 'firebase/app'; // 削除

import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';
import { Store } from '@ngrx/store'; // 追加

import { Password, User } from '../class/chat'; // 変更
import * as fromSession from '../app-store/reducers'; // 追加
import * as SessionActions from '../app-store/actions/session.actions'; // 追加

@Injectable({
  providedIn: 'root'
})
export class SessionService {

  // public session = new Session(); // 削除
  // public sessionSubject = new Subject<Session>(); // 削除
  // public sessionState = this.sessionSubject.asObservable(); // 削除

  constructor(private router: Router,
              private afAuth: AngularFireAuth,
              private afs: AngularFirestore,
              private store: Store<fromSession.State>) { // 追加
  }

  // ログイン状況確認
  checkLogin(): void { // 変更
    this.store.dispatch(SessionActions.loadSessions());
  }

  // ログイン状況確認(State)
  checkLoginState(): Observable<{ login: boolean }> { // 変更
    return this.afAuth
      .authState
      .pipe(
        map((auth: any) => {
          // ログイン状態を返り値の有無で判断
          return { login: !!auth };
        })
      );
  }

  login(account: Password): void { // 変更
    this.store.dispatch(SessionActions.loginSessions({ email: account.email, password: account.password }));
  }

  logout(): void { // 変更
    this.store.dispatch(SessionActions.logoutSessions());
  }

  /*　省略 */

  // ユーザーを取得 // 削除

```

## View（コンポーネント）にStateを反映

上記で設定したルートストアのStateを、チャット画面とヘッダーに反映します。
コンポーネントにStateを反映させたいときは、Reducerで設定したセレクタをコンストラクタでDIします。

```ts:app/chat/chat.component.ts
import { Comment, User } from '../class/chat';
import { AngularFirestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// import { SessionService } from '../service/session.service'; // 削除
import { Store } from '@ngrx/store'; // 追加
import * as fromSession from '../app-store/reducers'; // 追加

  /*　省略 */

  // DI（依存性注入する機能を指定）
  constructor(private db: AngularFirestore,
              private store: Store<fromSession.State>) { // 追加
    this.store.select(fromSession.getSession) // 変更
      .subscribe(data => {
        this.currentUser = data.user;
      });
  }
```

これでルートストアのStateをチャット画面に反映しました。
続いてヘッダーにStateを反映します。

```ts:app/header/header.component.ts
import { Component, OnInit } from '@angular/core';
import { SessionService } from '../service/session.service';
import { Session } from '../class/chat';
import { Observable } from 'rxjs'; // 追加
import { Store } from '@ngrx/store'; // 追加
import * as fromSession from '../app-store/reducers'; // 追加

  /*　省略 */

  public session$: Observable<Session>; // 追加
  // public login = false; // 削除

  constructor(private sessionService: SessionService,
              private store: Store<fromSession.State>) { // 変更
    this.session$ = this.store.select(fromSession.getSession);
  }

  ngOnInit() {
    // 削除
  }
```

```html:app/header/header.component.html
<nav class="navbar fixed-top navbar-dark bg-primary">
  <a class="navbar-brand" href="#">NgChat</a>
  <!-- ログイン状態で分岐 -->
  <span class="navbar-text" *ngIf="!(session$ | async)?.login"><!-- 変更 -->
    <a routerLink="/account/login">Login</a>
  </span>
  <span class="navbar-text" *ngIf="(session$ | async)?.login"><!-- 変更 -->
    <a routerLink="/account/login" (click)="logout()">Logout</a>
  </span>
  <!-- 分岐ここまで -->
</nav>
```
チャット画面と同様にルートストアをDIし、非同期処理に対応できるようhtmlも更新しました。

### 非同期処理時のローディングをヘッダーに追加

最後に、非同期処理時のローディングバーをヘッダーに追加します。
Reducerで指定した`loading`セレクタをヘッダーコンポーネントでDIし、`loadSessions()`の起動から`loadSessionsSuccess()`、もしくは`loadSessionsFail()`が完了するまでの状態を反映できるようにします。

`header.component.ts`に`loading$`、`header.component.html`にBootstrapのプログレスバーを追加します。

```ts:app/header/header.component.ts
  public loading$: Observable<boolean>; // 追加
  public session$: Observable<Session>; // 追加

  constructor(private sessionService: SessionService,
              private store: Store<fromSession.State>) { // 変更
    this.loading$ = this.store.select(fromSession.getLoading); // 追加
    this.session$ = this.store.select(fromSession.getSession);
  }
```
```html:app/header/header.component.html
<nav class="navbar fixed-top navbar-dark bg-primary">
  <a class="navbar-brand" href="#">NgChat</a>
  <!-- ログイン状態で分岐 -->
  <span class="navbar-text" *ngIf="!(session$ | async)?.login">
    <a routerLink="/account/login">Login</a>
  </span>
  <span class="navbar-text" *ngIf="(session$ | async)?.login">
    <a routerLink="/account/login" (click)="logout()">Logout</a>
  </span>
  <!-- 分岐ここまで -->
</nav>
<div class="progress" style="height: 3px;" *ngIf="(loading$ | async)"> <!-- 追加 -->
  <div class="progress-bar bg-info progress-bar-striped progress-bar-animated"
       role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
</div>
```
```css:app/header/header.component.css
.progress { /* 追加 */
  position: absolute;
  top: 0;
  width: 100%;
  z-index: 1050;
}
```

### 実行結果

![Oct-26-2018 17-27-23.gif](https://qiita-image-store.s3.amazonaws.com/0/112566/32f42db0-e280-121d-cb85-76f2d84d86be.gif)

これでルートストアの設定、およびViewへのStateの反映が完了しました。
次は機能ストアの設定、およびエンティティの設定を行います。

## ソースコード

この時点での[ソースコード](https://github.com/Yamamoto0525/NgChat/tree/529c9d0675ae76585d1c5c756aa17c3804e0933a)
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。