Angularのngrxを使って状態管理を行う（実装編：エンティティ設定）

> この記事は[Angular+Firebaseでチャットアプリを作る](http://qiita.com/Yamamoto0525/items/a76ea4b3924eeb82b0f9)のエントリーです。
> 前記事：[Angularのngrxを使って状態管理を行う（実装編：初期設定～エフェクト設定）](https://qiita.com/Yamamoto0525/items/6c31cd6b45d23e54637b)
> 次記事：[WEBアプリでFirebaseのデプロイ環境を構築する](https://qiita.com/Yamamoto0525/items/782a84cc92a14b35786c)

# この記事で行うこと

[前回の記事](https://qiita.com/Yamamoto0525/items/6c31cd6b45d23e54637b)ではngrxの状態管理の実装（初期設定～エフェクト設定）を扱いました。
本記事ではngrxの実装方法（機能ストア～エンティティ設定）について学習します。

- [機能ストアの設置](#機能ストアの設置)
- [Entityを用いたストア設計](#entityを用いたストア設計)
- [View（コンポーネント）にStateを反映](#viewコンポーネントにstateを反映)

### 機能ストアとは

公式ドキュメント内で機能ストアは`StoreModule.forFeature`として記載されています。
ルートストア（`StoreModule.forRoot`）がアプリケーション全体で使用できる一方、機能ストアは機能モジュール単位で利用できるストアになります。

Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。

機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で１つのオブジェクト』というReduxの理念を維持しています。

![Redux図 (6).png](https://qiita-image-store.s3.amazonaws.com/0/112566/bbacb8c0-d669-c93a-a8b9-a4ebf6b7c9fa.png)

> ルートモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。

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

# 実装内容

## 機能ストアの設置

### 機能モジュールを設定する

まず、機能ストアを登録するための機能モジュールと、Entityを扱うためのライブラリを追加します。
[以前の記事](https://qiita.com/Yamamoto0525/items/e870713d9d05d2d36a80#%E6%A9%9F%E8%83%BD%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%B8%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0)と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。

```bash
ng g module chat --routing
npm install @ngrx/entity --save
```

```ts:app/chat/chat.module.ts
import { NgModule } from '@angular/core';
// import { CommonModule } from '@angular/common'; // 削除

import { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../shared/shared.module'; // 追加
import { ChatComponent } from './chat.component'; // 追加


@NgModule({
  declarations: [
    ChatComponent, // 追加
  ],
  imports: [
    // CommonModule, // 削除
    SharedModule, // 追加
    ChatRoutingModule,
  ],
})
export class ChatModule { }
```
```ts:app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
// import { ChatComponent } from './chat/chat.component'; 削除
import { PageNotFoundComponent } from './error/page-not-found/page-not-found.component';
import { AccountModule } from './account/account.module';
import { AuthGuard } from './guard/auth.guard';
import { LoginGuard } from './guard/login.guard';

const routes: Routes = [
  {
    path: 'account',
    loadChildren: () => import('./account/account.module').then(m => m.AccountModule),
    canActivate: [LoginGuard],
  },
  {
    path: '',
    loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule), // 変更
    canActivate: [AuthGuard],
  },
  {
    path: '**',
    component: PageNotFoundComponent,
  },
];
```
```ts:app.module.ts
import { AppComponent } from './app.component';
// import { ChatComponent } from './chat/chat.component'; // 削除
import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './error/page-not-found/page-not-found.component';
import { AppStoreModule } from './app-store/app-store.module';

@NgModule({
  declarations: [
    AppComponent,
    // ChatComponent, // 削除
    HeaderComponent,
    PageNotFoundComponent,
  ],
```
```ts:app/chat/chat-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ChatComponent } from './chat.component'; // 追加

const routes: Routes = [
  { path: '', component: ChatComponent }, // 追加
];
```

### 機能ストアを設置する

機能ストアを設置する前に、どのようなステートツリーになるかを確認します。

```bash
state
 ├── session
 │ 　　├── session
 │ 　　└── loading
 └── chat
  　 　├── ids
 　　  ├── entities
  　 　└── loading
```

`session`stateはアプリケーション起動時に取得される一方、`chat`stateは`ChatModule`ダウンロード時に取得が開始されます。この前提をもとに、ジェネレータを使って機能ストアと機能エフェクトを設置します。

`ng g entitiy EntityName`を使うと、ID付きのオブジェクトを扱うAction、Reducerのテンプレートを自動生成し、指定したモジュールにそのStateを登録してくれます。

```bash
// 機能ストアの追加
ng g entity chat/store/Chat --module ../chat.module.ts
? Do you want to use the create function? Yes
// 機能エフェクトの追加
ng g effect chat/store/Chat --module chat/chat.module.ts
? Should we wire up success and failure actions? Yes
? Do you want to use the create function? Yes
```

```ts:app/chat/chat.module.ts
import { NgModule } from '@angular/core';
import { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../shared/shared.module';
import { ChatComponent } from './chat.component';
import { StoreModule } from '@ngrx/store';
import * as fromChat from './store/chat.reducer';
import { EffectsModule } from '@ngrx/effects';
import { ChatEffects } from './store/chat.effects';


@NgModule({
  declarations: [
    ChatComponent,
  ],
  imports: [
    SharedModule,
    ChatRoutingModule,
    StoreModule.forFeature(fromChat.chatsFeatureKey, fromChat.reducer),
    EffectsModule.forFeature([ChatEffects]),
  ],
})
```

これで機能ストアの設置は完了です。
次は自動作成されたAction、Reducer、Effectの内容を確認し、実装を行います。

## Entityを用いたストア設計

### Actionの設計

まずは自動作成されたActionの内容を確認します。

|Action名|概要|
|:-----------------|:------------------|
|loadChats|データ（複数）の読み込み|
|addChat|データ（個別）の追加|
|upsertChat|データ（個別）があれば更新、なければ追加|
|addChats|データ（複数）の追加|
|upsertChats|データ（複数）があれば更新、なければ追加|
|updateChat|データ（個別）の更新|
|updateChats|データ（複数）の更新|
|deleteChat|データ（個別）の削除|
|deleteChats|データ（複数）の削除|
|clearChats|全データのクリア|

本アプリケーションに必要なAction以外を削除し、また非同期処理成功、失敗時のActionを追加します。

|Action名|起動時|概要|
|:-----------------|:------------------|:------------------|
|loadChats|チャット画面訪問時|データ（複数）の読み込み|
|loadChatsSuccess|LoadChatsが成功|-|
|loadChatsFail|LoadChatsが失敗|-|
|addChat|Sendボタンクリック時|データ（個別）の追加|
|updateChat|編集で保存ボタンクリック時|データ（個別）の更新|
|deleteChat|削除ボタンクリック時|データ（個別）の削除|
|writeChatSuccess|書き込み処理が成功|-|
|writeChatChatFail|書き込み処理が失敗|-|

これをもとに、Actionファイルを更新します。
なお、自動で`chat.model.ts`というファイルが作成され、`Chat`をインポートしていますが、ここではすでに作成済の`Comment`クラスに差し替えます。

```ts:class/chat.ts
export class Comment {
  user: User;
  initial: string;
  content: string;
  date: number;
  id?: string; // 変更
  edit_flag?: boolean;

  constructor(user: User, content: string) {
    this.user = user;
    this.initial = user.name.slice(0, 1);
    this.content = content;
    this.date = +moment();
  }

  deserialize() {
    this.user = this.user.deserialize();
    return Object.assign({}, this);
  }

  // 取得した日付を反映し、更新フラグをつける
  setData(date: number, key: string): Comment {
    this.date = date;
    this.id = key; // 変更
    this.edit_flag = false;
    return this;
  }
}
```
```ts:chat/store/chat.actions.ts
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Comment } from '../../class/chat';

// import { Chat } from './chat.model'; // 削除

export const loadChats = createAction(
  '[Chat/API] Load Chats',
  (payload: { chats: Comment[] }) => ({ payload }),
);

export const loadChatsSuccess = createAction(
  '[Chat/API] Load Chats Success',
  (payload: { chats: Comment[] }) => ({ payload }),
);

export const loadChatsFail = createAction(
  '[Chat/API] Load Chats Fail',
  (payload?: { error: any }) => ({ payload }),
);

export const addChat = createAction(
  '[Chat/API] Add Chat',
  (payload: { chat: Comment }) => ({ payload }),
);

export const updateChat = createAction(
  '[Chat/API] Update Chat',
  (payload: { chat: Update<Comment> }) => ({ payload }),
);

export const deleteChat = createAction(
  '[Chat/API] Delete Chat',
  (payload: { id: string }) => ({ payload }),
);

export const writeChatSuccess = createAction(
  '[Chat/API] Write Chat Success',
  (payload?: { chats: Comment[] }) => ({ payload }),
);

export const writeChatChatFail = createAction(
  '[Chat/API] Write Chat Fail',
  (payload?: { error: any }) => ({ payload }),
);
```

LoadとWriteの非同期処理の結果を分けていますが、これはLoadでObservableを使用しているためです。Add、Update、Delete時にはその結果がLoadからも流れてくることになるので、処理をわけるようにしています。

また、UpdateChatアクションでは`Update<Comment>`というクラスを使用しています。このクラスはidと変更後のデータ(changes)をプロパティとして持っているので、Update時にはそれらを指定する必要があります。

続いてReducerの実装を行います。

```ts:chat/store/chat.reducer.ts
import { createReducer, on, createFeatureSelector, createSelector } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import * as ChatActions from './chat.actions';
import { Comment } from '../../class/chat';

export const chatsFeatureKey = 'chats';

export interface State extends EntityState<Comment> {
  loading: boolean;
}

export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();

export const initialState: State = adapter.getInitialState({
  loading: false,
});


export const reducer = createReducer(
  initialState,
  on(ChatActions.addChat, (state) => {
    return { ...state, loading: true };
  }),
  on(ChatActions.updateChat, (state, action) => {
      return adapter.updateOne(action.payload.chat, { ...state, loading: true });
    }
  ),
  on(ChatActions.deleteChat, (state, action) => {
    return adapter.removeOne(action.payload.id, { ...state, loading: true });
  }),
  on(ChatActions.loadChats, (state) => {
    return { ...state, loading: true };
  }),
  on(ChatActions.loadChatsSuccess, (state, action) => {
    return adapter.upsertMany(action.payload.chats, { ...state, loading: false });
  }),
  on(ChatActions.loadChatsFail, (state) => {
    return { ...state, loading: false };
  }),
  on(ChatActions.writeChatSuccess, (state) => {
    return { ...state, loading: false };
  }),
  on(ChatActions.writeChatFail, (state) => {
    return { ...state, loading: false };
  }),
);

const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
export const selectChat = createFeatureSelector<State>('chats');
export const getChatLoading = createSelector(selectChat, state => state.loading);
export const selectAllChats = createSelector(selectChat, selectAll);

```
少し分解しながら説明します。

```ts
export interface State extends EntityState<Comment> {
  loading: boolean;
}
```
このState は`EntityState<Comment>`の`ids: string[] | number[]`、`entities: {[id: string]: Comment}`というプロパティを継承しています。ここではAPI接続時のローディング用ステート（loading）を追加しています。

```ts
  on(ChatActions.addChat, (state) => {
    return { ...state, loading: true };
  }),
  on(ChatActions.updateChat, (state, action) => {
      return adapter.updateOne(action.payload.chat, { ...state, loading: true });
    }
  ),
  on(ChatActions.deleteChat, (state, action) => {
    return adapter.removeOne(action.payload.id, { ...state, loading: true });
  }),
  on(ChatActions.loadChats, (state) => {
    return { ...state, loading: true };
  }),
```

CLUD処理の開始時に、ローディングが始まるようにしています（`loading: true`）。`adapter.updateOne`は指定されたidのエンティティを更新し、`chatAdapter.removeOne`は指定されたidのエンティティを削除します。

`adapter: EntityAdapter<Comment>`はこれ以外にも`addAll`（すべて追加）や`removeAll`（すべて削除）といったメソッドを持っています。

```ts
  on(ChatActions.loadChatsSuccess, (state, action) => {
    return adapter.upsertMany(action.payload.chats, { ...state, loading: true });
  }),
  on(ChatActions.loadChatsFail, (state) => {
    return { ...state, loading: false };
  }),
  on(ChatActions.writeChatSuccess, (state) => {
    return { ...state, loading: false };
  }),
  on(ChatActions.writeChatFail, (state) => {
    return { ...state, loading: false };
  }),
```

`LoadChatSuccess`はLoad成功時に取得したデータをStateに反映させます。取得したエンティティがすでにある場合は更新、ない場合は追加をします。また、非同期処置が完了したので、ローディングを終了させます（`loading: false`）。

```ts
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
export const selectChat = createFeatureSelector<State>('chats');
export const getChatLoading = createSelector(selectChat, state => state.loading);
export const selectAllChats = createSelector(selectChat, selectAll);
```

`adapter: EntityAdapter<Comment>`から基本となるセレクタを取得しています。それぞれのセレクタからは、下記のデータが取得できます。

|メソッド名|取得データ|概要|
|:-----------------|:------------------|:------------------|
|selectIds|`string[] or number[]`|idの一覧を取得|
|selectEntities|`{[id: string]: Comment}`|エンティティ（オブジェクト）の一覧を取得|
|selectAll|`Comment[]`|エンティティ（配列）の一覧を取得|
|selectTotal|`number`|エンティティの総数を取得|

これでReducerの実装は完了です。最後にEffectの実装を行います。

```ts:app/chat/effects.ts
import { Injectable } from '@angular/core';
import { AngularFirestore } from '@angular/fire/firestore';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Update } from '@ngrx/entity';

import { Comment} from '../../class/chat';
import * as ChatActions from './chat.actions';

@Injectable()
export class ChatEffects {

  constructor(private actions$: Actions,
              private db: AngularFirestore) {
  }

  addChat$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ChatActions.addChat),
      map(action => action.payload.chat),
      switchMap((comment: any) => {
        return this.db
          .collection('comments')
          .add(comment)
          .then(() => ChatActions.writeChatSuccess())
          .catch(() => ChatActions.writeChatFail({ error: 'failed to add' }));
      }),
    ));

  updateChat$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ChatActions.updateChat),
      map(action => action.payload.chat),
      switchMap((comment: Update<Comment>) => {
        return this.db
          .collection('comments')
          .doc(comment.id.toString())
          .update({ content: comment.changes.content, date: comment.changes.date })
          .then(() => {
            alert('コメントを更新しました');
            return ChatActions.writeChatSuccess();
          })
          .catch(() => ChatActions.writeChatFail({ error: 'failed to update' }));
      }),
    ));

  deleteChat$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ChatActions.deleteChat),
      map(action => action.payload.id),
      switchMap((id: string) => {
        return this.db
          .collection('comments')
          .doc(id)
          .delete()
          .then(() => {
            alert('コメントを削除しました');
            return ChatActions.writeChatSuccess();
          })
          .catch(() => ChatActions.writeChatFail({ error: 'failed to delete' }));
      }),
    ));

  loadChats$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ChatActions.loadChats),
      map(action => action.payload.chats),
      switchMap(() => {
        return this.db.collection<Comment>('comments', ref => {
          return ref.orderBy('date', 'asc');
        }).snapshotChanges()
          .pipe(
            map(actions => actions.map(action => {
              // 日付をセットしたコメントを返す
              const data = action.payload.doc.data() as Comment;
              const key = action.payload.doc.id;
              const commentData = new Comment(data.user, data.content);
              commentData.setData(data.date, key);
              return commentData;
            })),
            map((result: Comment[]) => {
              return ChatActions.loadChatsSuccess({
                chats: result
              });
            }),
            catchError(this.handleChatsError(
              'fetchChats', ChatActions.loadChatsFail()
            ))
          );
      })
    ));

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

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

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

}
```
`chat.component.ts`内で行っていた処理を、こちらに移行しています。`SessionEffect`と同様、`ofType`でトリガーとなるActionを指定し、処理結果を新しいActionに渡しています。

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

Reducerで設定したStateを、Viewに反映させます。

```ts:chat/chat.component.ts
import { Component, OnInit } from '@angular/core';
import { Comment, User } from '../class/chat';
// import { AngularFirestore } from '@angular/fire/firestore'; // 削除
// import { Observable } from 'rxjs'; // 削除
// import { map } from 'rxjs/operators'; // 削除

import { Store } from '@ngrx/store';
import * as fromSession from '../app-store/reducers';
import * as fromChat from './store/chat.reducer'; // 追加
import * as ChatActions from './store/chat.actions'; // 追加

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

  public content = '';
  public comments: Comment[] = []; // 変更
  public currentUser: User;

  // DI（依存性注入する機能を指定）
  constructor(private chat: Store<fromChat.State>, // 追加
              // private db: AngularFirestore, // 削除
              private store: Store<fromSession.State>) {
    this.store
      .select(fromSession.getSession)
      .subscribe(data => {
        this.currentUser = data.user;
      });
    this.chat.select(fromChat.selectAllChats)
      .subscribe((comments: Comment[]) => {
        this.comments = [];
        comments.forEach((comment: Comment) => {
          this.comments.push(
            new Comment(comment.user, comment.content)
              .setData(comment.date, comment.id)
          );
        });
      }); // 追加
  }

  ngOnInit() { // 変更
    this.store.dispatch(ChatActions.loadChats({ chats: [] }));
  }

  // 新しいコメントを追加
  addComment(e: Event, content: string) { // 変更
    e.preventDefault();
    if (content) {
      const tmpComment = new Comment(this.currentUser, content).deserialize();
      this.chat.dispatch(ChatActions.addChat({ chat: tmpComment }));
      this.content = '';
    }
  }

  // 編集フィールドの切り替え
  toggleEditComment(comment: Comment) { // 変更
    comment.editFlag = !comment.editFlag;
  }

  // コメントを更新する
  saveEditComment(comment: Comment) { // 変更
    comment.editFlag = false;
    this.chat.dispatch(ChatActions.updateChat({ chat: { id: comment.id, changes: comment } }));
  }

  // コメントをリセットする
  resetEditComment(comment: Comment) { // 変更
    comment.content = '';
  }

  // コメントを削除する
  deleteComment(key: string) { // 変更
    this.chat.dispatch(ChatActions.deleteChat({ id: key }));
  }

}
```

機能ストアにdispatchを行い、Effectに移行した分を削除しました。
storeにあるデータはreadOnlyであるため、一時的なデータ利用はシャローコピーを行なって対応しています。

自分以外のコメントは編集・削除ができないようにHTMLも修正します。

```html:app/chat/chat.component.html
<!-- 自分のuidのときのみ、編集領域を表示 -->
<ng-container *ngIf="comment.user.uid === currentUser.uid"><!-- 追加 -->
<button class="btn btn-primary btn-sm" (click)="toggleEditComment(comment)">編集</button>
<button class="btn btn-danger btn-sm" (click)="deleteComment(comment.id)">削除</button>
</ng-container>
```

次に、ヘッダーコンポーネントからも機能ストアをDIして、ローディングを反映させます。
なお、コアモジュールで管理しているコンポーネントに読み込ませる場合、`ChatModule`にアクセスするまで機能ストアはundefinedになるので注意が必要です。

```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';
import * as fromChat from '../chat/store/chat.reducer'; // 追加

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

  public loadingSession$: Observable<boolean>; // 変更
  public loadingChat$: Observable<boolean>; // 追加
  public session$: Observable<Session>;

  constructor(private sessionService: SessionService,
              private store: Store<fromSession.State>,
              private chat: Store<fromChat.State>) { // 追加
    this.loadingSession$ = this.store.select(fromSession.getLoading); // 変更
    this.loadingChat$ = this.chat.select(fromChat.getChatLoading); // 追加
    this.session$ = this.store.select(fromSession.getSession);
  }

  ngOnInit() {
  }

  logout(): void {
    this.sessionService.logout();
  }

}
```

```html:core/header/header.component.html
<div class="progress" style="height: 3px;" *ngIf="(loadingSession$ | async) || (loadingChat$ | 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>
```

### 実行結果

![Oct-26-2018 17-46-31.gif](https://qiita-image-store.s3.amazonaws.com/0/112566/ba43b07b-0e5f-7c1d-a34b-320bea86c86f.gif)


これでngrxの実装は完了です。次回からはAngularとFirebaseの本番環境構築について書いていきます。

## ソースコード

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