LoginSignup
21
21

More than 3 years have passed since last update.

Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)

Last updated at Posted at 2018-10-30

この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(理論編)
次記事:Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)

この記事で行うこと

前回の記事ではngrxの概念と基本的な構成を扱いました。
本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。


(追記:2020/6)現時点(2020年6月)での最新の内容に書き換えています。


実装内容

ngrxの初期設定

ライブラリインストール

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

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の環境構築は過去の記事から行ってください。
また、この実装はAngular6以上であることが必要ですので注意してください。
(2020/06追記)
NgRx v7.4から Action Creator機能が追加され、記述の省略化ができるようになりました。本記事でもその機能を使って、コードを全面的に修正しています。
参考: NgRx v7.4で導入されるAction Creatorの使い方

@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

ステートツリー設計

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

state
 └── session
      ├── session(セレクタ)
      └── loading(セレクタ)

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

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

// ルートストアの追加
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のログ機能を実装しておきます。

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の拡張ツールを使って視覚的にステートツリーの状況確認を行うための開発用ツールです。拡張ツールをダウンロードした後、Chromeの開発者ツールから使用することができます。

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

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

// 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という3つの動作と、それぞれにSuccess、Failureという非同期の結果(Side Effect)を加えた計9種類のActionを定義しました。

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

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」も追加しておきます。

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が上書きされるようにしておきます。

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.tscreateSelector()を使ってセレクタを作成します。

app/app-store/reducers/session.reducer.ts
export const getSessionLoading = (state: State) => state.loading;
export const getSessionData = (state: State) => state.session;
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クラスのコンストラクタを更新します。

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);
    };
  }
}
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()メソッドに渡しています。
エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。

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

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内の記述を変更します。

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します。

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を反映します。

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() {
    // 削除
  }
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.tsloading$header.component.htmlにBootstrapのプログレスバーを追加します。

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);
  }
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>
app/header/header.component.css
.progress { /* 追加 */
  position: absolute;
  top: 0;
  width: 100%;
  z-index: 1050;
}

実行結果

Oct-26-2018 17-27-23.gif

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

ソースコード

この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。

21
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
21