みなさんこんにちは、現役エンジニアのサメハックです
未経験からWebエンジニアに転職し、
正社員として5年働いたのちフリーランスとして独立しました。
Angularの解説シリーズです。
今回はNGRXについて学んでいきましょう!
駆け出しエンジニアや未経験の方、
また新入社員を指導する先輩社員にとっても
わかりやすいように解説していきます!
- NGRXが理解できる
- STOREの操作ができる
- EFFECTSの処理が実装できる
- サンプルコードがダウンロードできる
※PCにnpm、nodeがインストールされている前提で記述します。
yarn等をお使いの方は読み替えてください。
何度も読み直してね!
作りたいもの
今回は以下のような処理を行うアプリケーションを作成します
- ボタン操作でEffectsを経由してHTTP通信
- ボタン操作でSTORE情報の更新
- SELECTOR経由でSTOREのデータを取得
■初期表示
■ユーザ名をクリック
■ユーザの詳細情報を取得
実行環境
うまく動かなければアップデートしてね!
NGRXとは
NGRXとは、Reduxをベースに作られたAngularで状態(STATE)管理をするためのシステムです。
このようなライフサイクルが公式で公表されていますが、
初見では非常に難解なので、今回の記事では
このライフサイクルを紐解いていきましょう。
結論
まず、このライフサイクルを理解できないと
NGRXの理解が非常に難しいのでいきなり結論から説明します。
データ処理は一方通行ではなく以下の2種類が存在する、と解釈してください。
- データ取得処理
- データ更新処理
上記の2処理を設定することで、
データ更新→自動的にデータ取得
という一方通行の流れが”最終的に”生まれる
と考えたほうが理解がしやすいです。
COMPONENTとは
COMPONENTというのは、いわゆる画面描画&操作に関わる部分です。
例えば
- ボタンをクリックする
- DOMのを書き換える
というような処理を司る部分です。
STORE
STOREというのは、STATE(状態)を保管している空間で
メモリ上に存在する仮想DBのようなイメージです。
SELECTOR
SELECTORではSTOREのデータ取得処理が定義されており、
コンポーネントから呼び出すことで、
データの取得と自動更新
を実装することができます。
少しややこしいので言い換えると
「STOREのデータを取得(自動連携)して!」
とCOMPONENTから依頼することで、
STORE→データの単方向データバインディングが行われるようになります。
SELECTORの宣言
export const セレクタ名 = createSelector(
State,
(state: 型) => state.渡したいプロパティ
)
SELECTORを呼び出す構文
const 変数名$ :Observable<型> = this.store.select(SELECTORで作成したセレクタ名);
この記述だけでは実行されません。
Observableが返されるので、変数名の後には慣例として$をつけよう!
ACTION
ACTIONとはイベントを管理するファイルです。
STOREの更新はACTION→REDUCER経由で行われます。
この際のイベントを管理・紐づけをしています。
ACTIONの宣言
export const 関数名 = createAction(
`[呼び出し元のコンポーネント] 処理の内容`,
props<{ 引数 }>()
);
ACTIONの実行
this.store.dispatch(ACTIONで宣言した関数());
REDUCER
NGRXを使用する上でREDUCERが最も重要なファイルです。
- STOREに保管するデータの定義
- STOREに保管されたデータの更新処理※ACTIONと紐づける
これらの機能を司ります。
REDUCERの宣言
export const REDUCER名 = createReducer(
initialState, //初期値
on(アクション名, (state, { 引数 }) => ({
// stateに対する更新処理
}))
);
on関数の第一引数でACTIONの関数と紐づけします。
EFFECTS
EFFECTSは情報が少なく理解が難しいのですが、
一言でいうとservice経由でAPIを叩き、受け取った値をSTOREへ登録する
という機能があります。
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へ登録する
実際に作ってみよう!
初期設定
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 }>()
);
ただのテキストなので、編集しても処理に影響はないよ!
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 });
})
);
})
);
});
}
という処理が行われるよ!
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}`);
}
}
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ではスプレッド構文が多様されます。
自信がない方は以下の記事を参考にしてください。
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 }));
}
}
これは見分けをつけるための命名ルールであって、$自体に意味はないよ!
【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の値を取得することができます。
詳しく知りたい方は以下の記事で解説していますので、ご参照ください。
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 {}
EffectsModule.forRoot([AppEffects]),
の記述を忘れると、NGRXが動かないよ!!
動作確認
■初期表示
■ユーザ名をクリック
■ユーザの詳細情報を取得
上記にアクセスすると、同じものができていると思います。
上手く動かない際にはエラーメッセージを確認してみてください。
NGRXは難しい概念なので、何度も読み返してゆっくり理解しよう!
デバッグツールのインストール
このままでは、STOREのSTATEの状態を確認することが出来ないので、
専用のデバッグツールをインストールします。
これをインストールすると、このように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が即使用できるシンプルなサンプルコードはなかなか見つからないので、
これを元に改版していただくと良いかと思います。
まとめ
今回の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フォローしてもらえると嬉しいです🦈