【Angular】EFFECTSを使ったシステムを世界一わかりやすく解説!【NGRX】

ANGULAR

みなさんこんにちは、現役エンジニアのサメハックです

未経験からWebエンジニアに転職し、
正社員として5年働いたのちフリーランスとして独立しました。

Angularの解説シリーズです。

今回はNGRXについて学んでいきましょう!

駆け出しエンジニアや未経験の方、
また新入社員を指導する先輩社員にとっても
わかりやすいように解説していきます!

この記事を読むと・・・
  • NGRXが理解できる
  • STOREの操作ができる
  • EFFECTSの処理が実装できる
  • サンプルコードがダウンロードできる

※PCにnpm、nodeがインストールされている前提で記述します。
 yarn等をお使いの方は読み替えてください。

ちなみに今回の内容は難易度がかなり高いので、
何度も読み直してね!

作りたいもの

今回は以下のような処理を行うアプリケーションを作成します

  • ボタン操作でEffectsを経由してHTTP通信
  • ボタン操作でSTORE情報の更新
  • SELECTOR経由でSTOREのデータを取得

■初期表示

■ユーザ名をクリック

■ユーザの詳細情報を取得

これが今回の完成形だよ!

実行環境

Angularのバージョンが古いと動かないことがあるよ!
うまく動かなければアップデートしてね!

NGRXとは

NGRXとは、Reduxをベースに作られたAngularで状態(STATE)管理をするためのシステムです。

このようなライフサイクルが公式で公表されていますが、
初見では非常に難解なので、今回の記事では
このライフサイクルを紐解いていきましょう。

結論

まず、このライフサイクルを理解できないと
NGRXの理解が非常に難しいのでいきなり結論から説明します。

データ処理は一方通行ではなく以下の2種類が存在する、と解釈してください。

  • データ取得処理
  • データ更新処理

上記の2処理を設定することで、
データ更新→自動的にデータ取得
という一方通行の流れが”最終的に”生まれる
と考えたほうが理解がしやすいです。

Effectsについては後述するよ!

COMPONENTとは

COMPONENTというのは、いわゆる画面描画&操作に関わる部分です。

例えば

  • ボタンをクリックする
  • DOMのを書き換える

というような処理を司る部分です。

STORE

STOREというのは、STATE(状態)を保管している空間で
メモリ上に存在する仮想DBのようなイメージです。

NGRXではSTOREに保管した情報を更新/取得するよ!

SELECTOR

SELECTORではSTOREのデータ取得処理が定義されており、
コンポーネントから呼び出すことで、

データの取得と自動更新
を実装することができます。

少しややこしいので言い換えると
「STOREのデータを取得(自動連携)して!」
とCOMPONENTから依頼することで、
STORE→データの単方向データバインディングが行われるようになります。

感覚的にはgetter()に近いよ!

SELECTORの宣言

export const セレクタ名 = createSelector(
  State,
  (state: 型) => state.渡したいプロパティ
)

SELECTORを呼び出す構文

const 変数名$ :Observable<型> = this.store.select(SELECTORで作成したセレクタ名);

この記述だけでは実行されません。

HTMLファイルで

のように記述することで初めて実行されるよ!

Observableが返されるので、変数名の後には慣例として$をつけよう!

ACTION

ACTIONとはイベントを管理するファイルです。

STOREの更新はACTION→REDUCER経由で行われます。
この際のイベントを管理・紐づけをしています。

ACTIONの宣言

export const 関数名 = createAction(
  `[呼び出し元のコンポーネント] 処理の内容`,
  props<{ 引数 }>()
);
propsは必要な場合のみ記述するよ!

ACTIONの実行

this.store.dispatch(ACTIONで宣言した関数());
実際に行われる処理はREDUCERで定義されるよ!

REDUCER

NGRXを使用する上でREDUCERが最も重要なファイルです。

  • STOREに保管するデータの定義
  • STOREに保管されたデータの更新処理※ACTIONと紐づける

これらの機能を司ります。

これはsetter()に近いよ!

REDUCERの宣言

export const REDUCER名 = createReducer(
  initialState, //初期値
  on(アクション名, (state, { 引数 }) => ({
    // stateに対する更新処理
  }))
);

on関数の第一引数でACTIONの関数と紐づけします。

ここでACTIONの実際の処理を定義するよ!

EFFECTS

EFFECTSは情報が少なく理解が難しいのですが、
一言でいうとservice経由でAPIを叩き、受け取った値をSTOREへ登録する
という機能があります。

EFFECTSはオプション的な機能なので、システムによっては不要だよ!

EFFECTSの構文

EFFECTS名 = createEffect((): any => {
  return this.actions$.pipe(
    // AppAction.fetchUserDetailがdispatchされたら実行する
    ofType(ACTION名1),
    switchMap(({ 引数 }) => {
      // serviceファイルのfetchXXXを実行
      return this.service.fetchXXX(id).pipe(
        // fetchXXXの戻り値を受け取る
        map((response: any) => {
          return ACTION名2({ response });
        })
      );
    })
  );
});

ちょっとややこしいですが、ポイントとしては以下の3点です。

  • EFFECTS名が呼ばれることは基本的にない
  • ACTION名1がdispatchされた際に実行される
  • ACTION名2→REDUCER経由でSTOREへ登録する
後述しますが、moduleへ登録しないとACTION名1とEFFECTSが紐づかないよ!

実際に作ってみよう!

初期設定

Windows用

ng new my-app
cd my-app

npm install 

npm install @ngrx/store --save
npm install @ngrx/store-devtools --save
npm install @ngrx/effects --save 
npm install rxjs
ng generate service services/app

mkdir -p src\app\ngrx

type nul > src/app/ngrx/app.reducer.ts 
type nul > src/app/ngrx/app.action.ts 
type nul > src/app/ngrx/app.selector.ts 
type nul > src/app/ngrx/app.effects.ts 

ng serve -o

Mac用

ng new my-app
cd my-app

npm install 

npm install @ngrx/store --save
npm install @ngrx/store-devtools --save
npm install @ngrx/effects --save 
npm install rxjs
ng generate service services/app

mkdir -p src\app\ngrx

touch src/app/ngrx/app.reducer.ts 
touch src/app/ngrx/app.action.ts 
touch src/app/ngrx/app.selector.ts 
touch src/app/ngrx/app.effects.ts 

ng serve -o

補足

何をやっているのかについての補足を入れておきます

ng new my-app
cd my-app

npm install 

// STOREのインストール※effects以外はインストールされる 
npm install @ngrx/store --save
// STOREツールのインストール
npm install @ngrx/store-devtools --save
// STOREのインストール
npm install @ngrx/effects --save 
// rxjsのインストール
npm install rxjs

// 関連ファイルの作成
ng generate service services/app
mkdir -p src\app\ngrx
type nul > src/app/ngrx/app.reducer.ts 
type nul > src/app/ngrx/app.action.ts 
type nul > src/app/ngrx/app.selector.ts 
type nul > src/app/ngrx/app.effects.ts 

// アプリケーションの起動
ng serve -o

【ACTION】app.action.ts

import { createAction, props } from '@ngrx/store';
import * as AppReducer from './app.reducer';

///////////////////////////全ユーザ取得///////////////////////////////////

/**全ユーザ取得時に呼び出し※APIを実行 */
export const fetchAllUsers = createAction(`[App Component] Fetch All Users`);

/**全ユーザ取得完了時に呼び出し※STOREへ登録 */
export const fetchAllUsersDone = createAction(
  `[App Component] Fetch All Users Done`,
  props<{ fetchedUsers: AppReducer.User[] }>()
);

///////////////////////////ユーザ詳細取得/////////////////////////////////

/**ユーザ詳細取得時に呼び出し※APIを実行 */
export const fetchUserDetail = createAction(
  `[App Component] Fetch User Detail`,
  props<{ id: number }>()
);

/**ユーザ詳細取得完了時に呼び出し※STOREへ登録 */
export const fetchUserDetailDone = createAction(
  `[App Component] Fetch User Detail Done`,
  props<{ fetchedUser: AppReducer.UserDetail }>()
);

//////////////////////////////////////////////////////////////

/**選択したユーザのselectedプロパティをtrueにする */
export const updateSelectedState = createAction(
  `[App Component] Update Selected State`,
  props<{ id: number }>()
);
createActionの第一引数はデバッグツールで表示されるもので
ただのテキストなので、編集しても処理に影響はないよ!

fetchAllUsersとfetchAllUsersDone
fetchUserDetailとfetchUserDetailDone
これらはセットで動作するよ!

【EFFECTS】app.effects.ts


import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { exhaustMap, map, switchMap } from 'rxjs/operators';
import { AppService } from '../services/app.service';
import * as AppAction from './app.action';
import * as AppReducer from './app.reducer';

@Injectable()
export class AppEffects {
  constructor(private actions$: Actions, private service: AppService) {}

  fetchAllUsers$ = createEffect((): any => {
    return this.actions$.pipe(
      // AppAction.fetchAllUsersがdispatchされたら実行する
      ofType(AppAction.fetchAllUsers),
      switchMap(() => {
        // serviceファイルのfetchUsersを実行する
        return this.service.fetchUsers().pipe(
          // fetchUsersの戻り値を受け取る
          map((response: any) => {
            // 必要なプロパティのみを抽出してUser型として宣言
            const fetchedUsers: AppReducer.User[] = [];
            response.forEach((user: AppReducer.UserDetail) => {
              // User型に該当するプロパティのみを抽出
              const userInfo = {
                id: user.id,
                name: user.name,
                selected: false,
              };
              // 配列へ追加
              fetchedUsers.push(userInfo);
            });
            console.log('fetchedUsers', fetchedUsers);
            // fetchAllUsersDoneへ値を渡す
            // ※STOREへの登録はReducerで定義される
            return AppAction.fetchAllUsersDone({ fetchedUsers });
          })
        );
      })
    );
  });

  fetchUserDetail$ = createEffect((): any => {
    return this.actions$.pipe(
      // AppAction.fetchUserDetailがdispatchされたら実行する
      ofType(AppAction.fetchUserDetail),
      // 引数を受け取る
      switchMap(({ id }) => {
        // serviceファイルのfetchUserDetailを実行する
        return this.service.fetchUserDetail(id).pipe(
          map((fetchedUser: any) => {
            console.log('fetchedUser', fetchedUser);
            // fetchAllUsersDoneへ値を渡す
            // ※STOREへの登録はReducerで定義される
            return AppAction.fetchUserDetailDone({ fetchedUser });
          })
        );
      })
    );
  });
}
このファイルでAPIを叩く→STOREへ登録
という処理が行われるよ!

app.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

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

  /**http通信をして全てのユーザデータを取得 */
  fetchUsers() {
    console.log('fetchUsers');
    return this.http.get('https://jsonplaceholder.typicode.com/users');
  }

  /**http通信をして指定したIDのユーザの詳細データを取得 */
  fetchUserDetail(id: number) {
    console.log('fetchUserDetail');
    return this.http.get(`https://jsonplaceholder.typicode.com/users/${id}`);
  }
}
effectsファイルで実行する関数はここに定義されているよ!
AngularではAPIを叩く処理は基本的にserviceファイルで定義するよ!

【REDUCER】app.reducer.ts

import { createReducer, on } from '@ngrx/store';
import { __asyncGenerator } from 'tslib';
import * as AppAction from './app.action';

// デバッグツールでの表示名
export const featureName = 'main';

// 型の定義
export interface User {
  id: number;
  name: string;
  selected?: boolean;
}

// 型の定義
export interface UserDetail {
  id: number;
  name: string;
  username: string;
  email: string;
  address: {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
    geo: {
      lat: string;
      lng: string;
    };
  };
  phone: string;
  website: string;
  company: {
    name: string;
    catchPhrase: string;
    bs: string;
  };
}

// 型の定義
export interface State {
  users: User[];
  userDetail: UserDetail;
}

// 初期値の設定
export const initialState: State = {
  users: [],
  userDetail: null,
};

export const appReducer = createReducer(
  initialState,
  // 全てのユーザをSTOREに登録
  on(AppAction.fetchAllUsersDone, (state, { fetchedUsers }) => ({
    ...state,
    users: fetchedUsers,
  })),
  // 指定したユーザの詳細データをSTOREに登録
  on(AppAction.fetchUserDetailDone, (state, { fetchedUser }) => ({
    ...state,
    userDetail: fetchedUser,
  })),
  // 選択したユーザのselectedをtrueに、他のユーザをfalseに
  on(AppAction.updateSelectedState, (state, { id }) => ({
    ...state,
    users: state.users.map((user) => ({
      ...user,
      selected: user.id === id ? true : false,
    })),
  }))
);

ちなみに、Angularではスプレッド構文が多様されます。
自信がない方は以下の記事を参考にしてください。

【JavaScript&TypeScript】スプレッド構文を使おう!【...Object】
ここでデータ構造やACTIONのSTORE更新処理を定義しているよ!
effectsファイルで呼び出したxxxxDoneの処理もここに定義されているよ!

【SELECTOR】app.selector.ts

import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as AppReducer from './app.reducer';

// 型の指定がないと動かない場合があります。
export const selectUsersState = createFeatureSelector(
  AppReducer.featureName
);

/**ユーザの全情報を取得するセレクター */
export const selectAllUsers = createSelector(
  selectUsersState,
  (state: AppReducer.State) => state.users
);

/**ユーザの詳細情報を取得するセレクター */
export const selectUserDetail = createSelector(
  selectUsersState,
  (state: AppReducer.State) => state.userDetail
);

export const selectSelectedUser = createSelector(
  selectUsersState,
  (state: AppReducer.State) =>
    // selected === trueのユーザを探して返す
    state.users.find((user: AppReducer.User) => user.selected === true)
);
ここでコンポーネントに渡すデータを定義するよ!

【COMPONENT】app.component.ts

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as AppSelector from './ngrx/app.selector';
import * as AppAction from './ngrx/app.action';
import * as AppReducer from './ngrx/app.reducer';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'Angular-NGRX-effects';
  constructor(private store: Store) {}

  /**全てのユーザの情報を取得
   * */
  users$: Observable = this.store.select(
    AppSelector.selectAllUsers
  );
  /**選択ユーザの取得 */
  selectedUser$: Observable = this.store.select(
    AppSelector.selectSelectedUser
  );
  /**ユーザの詳細情報取得 */
  userDetail$: Observable = this.store.select(
    AppSelector.selectUserDetail
  );

  ngOnInit() {
    // 全てのユーザを取得
    this.store.dispatch(AppAction.fetchAllUsers());
  }

  // 選択したユーザのselectedプロパティをtrueにする
  onUpdateSelectedState(id: number) {
    this.store.dispatch(AppAction.updateSelectedState({ id }));
  }

  // 選択したユーザの詳細情報を取得
  onFetchUserDetail(id: number) {
    this.store.dispatch(AppAction.fetchUserDetail({ id }));
  }
}
Observableを受け取る変数名にはダラー$をつけるのがAngularのルールだよ!
これは見分けをつけるための命名ルールであって、$自体に意味はないよ!

【COMPONENT】app.component.html

<!-- users$を実行し、値を受け取ったらコンポーネントを描画 -->
<ng-container *ngIf="users$ | async as users">
  <!-- 取得した全てのユーザの情報をループ -->
  <div *ngFor="let user of users">
    <!-- クリックでユーザのidを渡す -->
    <button (click)="onUpdateSelectedState(user.id)">
      {{ user.id }}: {{ user.name }}
    </button>
  </div>
</ng-container>

<!-- ユーザを選択したら表示される -->
<div *ngIf="selectedUser$ | async as selectedUser; else UnSelected">
  <p>選択されたユーザー</p>
  <p>{{ selectedUser.id }}: {{ selectedUser.name }}</p>
  <button (click)="onFetchUserDetail(selectedUser.id)">
    ユーザの詳細情報を取得
  </button>
</div>
<!-- ユーザ未選択の場合に表示される -->
<ng-template #UnSelected> ユーザを選択してください </ng-template>

<!-- ユーザの詳細情報取得完了後に表示される -->
<div *ngIf="userDetail$ | async as userDetail">
  <p>詳細情報</p>
  <div>
    id:{{ userDetail.id }} <br />
    name:{{ userDetail.name }} <br />
    username:{{ userDetail.username }} <br />
    email:{{ userDetail.email }} <br />
    phone:{{ userDetail.phone }} <br />
    website:{{ userDetail.website }} <br />
  </div>
</div>

user$ | async as user

の部分だけがAngularの特殊な記述かと思います。

これを記述することで、初めてselectorの値を取得することができます。

詳しく知りたい方は以下の記事で解説していますので、ご参照ください。

【Angular】asyncパイプを使おう!【サンプルあり!】
他に使っている技術は、条件分岐・繰り返し・クリックイベントだよ!

app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { appReducer, featureName } from './ngrx/app.reducer';
import { EffectsModule } from '@ngrx/effects';
import { AppEffects } from './ngrx/app.effects';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    // Storeに登録
    StoreModule.forRoot({ [featureName]: appReducer }),
    // Effectsを使用できるようにする
    EffectsModule.forRoot([AppEffects]),
    StoreDevtoolsModule.instrument(),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
StoreModule.forRoot({ [featureName]: appReducer }),
EffectsModule.forRoot([AppEffects]),
の記述を忘れると、NGRXが動かないよ!!

動作確認

■初期表示

■ユーザ名をクリック

■ユーザの詳細情報を取得

http://localhost:4200/

上記にアクセスすると、同じものができていると思います。

上手く動かない際にはエラーメッセージを確認してみてください。

これでようやくアプリケーションが動くようになったね!
NGRXは難しい概念なので、何度も読み返してゆっくり理解しよう!

デバッグツールのインストール

このままでは、STOREのSTATEの状態を確認することが出来ないので、
専用のデバッグツールをインストールします。

Redux DevTools
Redux DevTools for debugging application's state changes.

これをインストールすると、このようにSTATEの値や、
ACTION実行時のログ、及び差分を表示することができるようになります。

できることが多いので色々試してみよう!

上手く動かないときや各ファイルを作成する際のポイント

ACTION

  • 引数を渡す際には{ }で必ず囲うこと
  • 必ず正しい型を指定すること

EFFECTS

  • 必ず正しい型を指定すること
  • 引数を受け取る際には{ }で囲うこと
  • 呼び出すためのアクション/STORE登録するためのアクションをセットで使う
  • ofType() で指定したアクションがdispatchされた際に実行される
  • STORE登録するためのアクションをreturnすること

REDUCER

  • 引数を受け取る際には{ }で必ず囲うこと
  • 必ず正しい型を指定すること
  • 未設定となる可能性のあるプロパティには、”?”をつけること
  • ネストしたプロパティを更新したい場合はmap関数を使う
  • nullを許容したい場合、tsconfig.jsonのcompilerOptionsに以下を追記すること
  • “strictNullChecks”: false

SELECTOR

  • 必ず正しい型を指定すること
  • 特定のデータを取得する場合には、findやfilterがよく使われる

COMPONENT

  • 必ず正しい型を指定すること
  • 引数を渡す際には{ }で必ず囲うこと
  • dispatchに渡すActionには()をつけること
  • Selectorの戻り値であるObservableを受け取る変数名の末尾に$をつけること
  • ※これはマストではありませんが、Angularの慣例なので極力付けましょう。
  • select関数は宣言するだけでは実行されない
  • html側で | asyncを使用することで実行される
  • select関数をtsファイルで実行したい場合には以下のように記述する
  • this.store.select(セレクタ名).subscribe((response)=>{ 処理 });
  • ※この場合ngOnDestroyする際にunsubscribe()がマストになります

サンプルコードダウンロード

今回使用したサンプルコードはGitHubに置いておきました。
NGRXが即使用できるシンプルなサンプルコードはなかなか見つからないので、
これを元に改版していただくと良いかと思います。

GitHub - same-hack/Angular-NGRX-effects
Contribute to same-hack/Angular-NGRX-effects development by creating an account on GitHub.

まとめ

今回のNGRXを用いたSTORE操作の処理とEFFECTSでのサービス実行をわかりやすくまとめました。

筆者も理解するのに時間がかかった概念なので、
世界一わかりやすくまとめたつもりです。

NGRXを使用すると、ファイルがかなり多くなってしまい
混乱すると思いますが、こうして見てみると
やっていることは非常に理解しやすいと思います。

特にEFFECTSを使うとより混乱すると思いますが、
ただ単にサービスの実行をしているだけ!
と考えるとシンプルに捉えられると思います。

  • NGRX・・・Angularで状態(STATE)管理をするためのシステム
  • STORE・・・STATEを管理している空間。メモリ上の仮想DBのようなイメージ
  • COMPONENT・・・画面描画&操作に関わる部分
  • SELECTOR・・・データ取得処理の定義。getter()のようなもの
  • ACTION・・・イベントの管理・紐づけをしています。
  • EFFECTS・・・主にservice経由でAPIを叩き、受け取った値をSTOREへ登録する
  • REDUCER・・・STOREのSTATEを更新する。setter()のようなもの

満足いただけたら、1クリックなのでSNSフォローしてもらえると嬉しいです🦈

タイトルとURLをコピーしました