みなさんこんにちは、現役フリーランスエンジニアのサメハックです
未経験からWebエンジニアに転職し、
現在正社員として5年働いたのちフリーランスとして独立しました。
Angularの解説シリーズです。
今回はNgRxについて学んでいきましょう!
駆け出しエンジニアや未経験の方、
また新入社員を指導する先輩社員にとっても
わかりやすいように解説していきます!
- NgRxが何かがなんとなくわかる
- STOREの操作ができる
- サンプルコードがダウンロードできる
※PCにnpm、nodeがインストールされている前提で記述します。
yarn等をお使いの方は読み替えてください。
何度も読み直してね!
作りたいもの


このようにボタン操作でSTOREのSTATE状態を更新し、
画面を自動的に書き換えるアプリケーションを作成します。
NgRxとは
NgRxとは、Reduxをベースに作られた状態(STATE)管理をするためのシステムです。

このようなライフサイクルが公式で公表されていますが、
初見では非常に難解なので、
今回はこのライフサイクルを紐解いていきましょう。
結論
まず、このライフサイクルを理解できないと
NGRXの理解が非常に難しいのでいきなり結論から説明します。

ライフサイクル(データ処理)はこのようになっています。
一般的にNGRXは一方通行のデータ処理を行う
と言われていますが、
データ処理は一方通行ではなく、
- データ更新処理
- データ取得処理
の2種類が存在する、と解釈してください。
上記の2処理を設定することで、
データ更新→自動的にデータ取得
という一方通行の流れが”最終的に”生まれます。
COMPONENT
COMPONENTというのは、いわゆる画面描画&操作に関わる部分です。
例えば
- ボタンをクリックする
- DOMのを書き換える
というような処理を司る部分です。
STORE
STOREというのは、STATE(状態)を管理している空間で
メモリ上に存在する仮想DBのようなイメージです。
STOREが保持しているSTATEが更新されると、
データ連携しているCOMPONENTが自動的に書き換わります。
このデータの流れがまさにNGRXのライフサイクルです。
例えば画面操作によってSTATEが更新された際に、
特別な処理をしなくてもDOMが更新されるよ!
データ取得処理:SELECTOR
NgRxでは、SELECTORというファイルにSTOREのデータ取得および整形処理が
定義されており、COMPONENTと紐付けることで
STATEが書き換わったタイミングで、自動的に画面を書き換えることができます。
少しややこしいので言い換えると
「STOREのデータを取得(自動連携)して!」
とCOMPONENTから依頼することで、
STOREデータ→コンポーネントの単方向データバインディングが行われるようになります。
データ更新処理:REDUCER , ACTION
NgRxではREDUCERというファイルにSTOREのデータ更新処理が定義されます。
「え?ACTIONじゃなくて?」と思った方も多いと重いますが、
ACTIONはどこでどんな処理が行われているかを定義しているだけで、
実際のデータ更新処理は定義されていません。
後述しますが、専用のデバッグツールでのログを出すことと
引数を渡すのがActionの主な用途です。
EFFECT
今回は割愛しますが、外部APIを叩いてデータを取得する場合などには
EFFECTを使用します。
実際に作ってみよう!
ファイル作成・インストール

今回はファイルが多いので、
インストール作業・ファイル作成作業は始めに済ませておきます。
初期設定
ng new ngrx-user
cd ngrx-user
ユーザコンポーネントの作成
ng generate component user
ACTIONファイルの作成
touch src/app/user/user.action.ts
REDUCERファイルの作成
touch src/app/user/user.reducer.ts
SELECTORファイルの作成
touch src/app/user/user.selector.ts
モジュールの作成
ng generate module --flat user/user
Storeのインストール
npm install @ngrx/store --save
デバッグツールのインストール
npm install @ngrx/store-devtools --save
データ更新処理の作成

user.action.ts
■ACTION作成構文
import { createAction } from '@ngrx/store';
createAction(`[呼び出されるコンポーネント名]関数名`, props等のオプション)
/* ____________________
ACTION
各アクションがどこで呼ばれて、どのような処理をするのかを記述
引数が必要であれば、ここで定義する。
基本的にデバッグツールのログ確認用途で役に立ちます。
createAction(`[呼び出されるコンポーネント名]関数名`, props等のオプション)
____________________ */
import { createAction } from '@ngrx/store';
export const countUpAction = createAction(`[User Component] countUpAction`);
export const countDownAction = createAction(`[User Component] countDownAction`);
export const changeNameAction = createAction(
`[User Component] changeNameAction`
);
ただのテキストなので、編集しても処理に影響はないよ!
user.reducer.ts
■REDUCER作成構文
import { createReducer, on } from '@ngrx/store';
createReducer(
initialState,
on(アクション名, (state) => ({
...state,
STATEの更新処理,
}))
import { createReducer, on } from '@ngrx/store';
import { __asyncGenerator } from 'tslib';
import {
countUpAction,
countDownAction,
changeNameAction,
} from './user.action';
// デバッグツールでの表示名
export const featureName = 'user';
// 型の定義
export interface MyUserState {
name: string;
age: number;
}
/**初期値の設定 ※これをSTOREで状態管理する */
export const initialState: MyUserState = {
name: 'サメハック',
age: 100,
};
/**実際にSTOREのSTATEを操作する処理
createReducer(
initialState,
on(アクション名, (state) => ({
...state,
STATEの更新処理,
})),
*/
export const userReducer = createReducer(
initialState,
// countUpActionが呼び出されたら、STOREのstateを受け取る
// 受け取ったstateを展開し、ageプロパティの値を+1する
on(countUpAction, (state) => ({
...state,
age: state.age + 1,
})),
on(countDownAction, (state) => ({
...state,
age: state.age - 1,
})),
on(changeNameAction, (state) => ({
...state,
name: 'いぬハック',
}))
);

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

データ取得(自動連携)処理の作成

user.selector.ts
■SELECTOR作成構文
/* ____________________
SELECTOR
・STOREのデータを取得(連携)するための処理
イメージ的には、getter()のようなもの。
・STOREデータ → コンポーネント
の単方向データバインディングが行われる
createSelector(
State,(全てのstate情報) => 欲しいデータを取り出す)
____________________ */
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { MyUserState, featureName } from './user.reducer';
export const selectUserState = createFeatureSelector(featureName);
/**ユーザの全情報を取得するセレクター */
export const selectUserAllInfo = createSelector(
selectUserState,
(state) => state
);
/**ユーザの年齢情報を取得するセレクター */
export const selectUserAge = createSelector(
selectUserState,
(state) => state.age
);
コンポーネント
app.component.html
<p>user works!</p>
<h2>ユーザ情報をすべて取得</h2>
<div *ngIf="user$ | async as user">
name: {{ user.name }}
<button (click)="changeName()">名前を変更</button>
<br />
age: {{ user.age }}
<button (click)="countUp()">+</button>
<button (click)="countDown()">ー</button>
</div>
<h2>ユーザの年齢のみ取得</h2>
<div *ngIf="userAge$ | async as userAge">
age: {{ userAge }}
<button (click)="countUp()">+</button>
<button (click)="countDown()">ー</button>
</div>
特に特殊な記述はないですが、
user$ | async as user
の部分だけがAngularの特殊な記述かと思います。
変数名$ | async as 変数別名
これはasync pipeといい、上記のように記述することで
TypeScript側へ記述を最小限にして、STOREのデータを取得することができます。
*ngIfと併用することでSTOREからのデータ取得完了後に
はじめてコンポーネントが描画されます。
※この記述をしないとデータ収録前にコンポーネントが描画されてしまいエラーが発生します。
詳しく知りたい方はこちらの記事を参考にしてください

user.component.ts
■select・・・STOREのデータを受け取るための記述
変数名 = this.store.select(SELECTORで作成した変数);
■dispatch・・・STOREのデータを更新するための記述
this.store.dispatch(アクション名());
import { Component, OnInit } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { MyUserState } from './user.reducer';
import { selectUserAllInfo, selectUserAge } from './user.selector';
import {
changeNameAction,
countUpAction,
countDownAction,
} from './user.action';
@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.scss'],
})
export class UserComponent implements OnInit {
/**ユーザの全情報を取得 */
user$ = this.store.select(selectUserAllInfo);
/**ユーザの年齢を取得 */
userAge$ = this.store.select(selectUserAge);
constructor(private store: Store) {}
// STOREデータの更新
countUp() {
this.store.dispatch(countUpAction());
}
// STOREデータの更新
countDown() {
this.store.dispatch(countDownAction());
}
// STOREデータの更新
changeName() {
this.store.dispatch(changeNameAction());
}
ngOnInit(): void {}
}
ちなみにですが、この時点ではuser$の値は取得されません。
html側でasync pipeを実装することで初めてuser$の値を取得する処理が実行されます。
モジュール関連
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 { UserComponent } from './user/user.component';
import { UserModule } from './user/user.module';
@NgModule({
declarations: [AppComponent, UserComponent],
// ユーザモジュールのインポート
imports: [BrowserModule, AppRoutingModule, UserModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
user.module.ts
■STOREを使用できるようにする
import { StoreModule } from '@ngrx/store';
StoreModule.forRoot({ [featureName]: リデューサー名 }),
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { featureName, userReducer } from './user.reducer';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@NgModule({
declarations: [],
imports: [
CommonModule,
// Storeを使用できるようにする設定
StoreModule.forRoot({ [featureName]: userReducer }),
// デバッグツールのインポート
StoreDevtoolsModule.instrument(),
],
})
export class UserModule {}
画面表示
app.component.html
<app-user></app-user>
NgRxは難しい概念なので、何度も読み返してゆっくり理解しよう!
デバッグツールのインストール

このままでは、STOREのSTATEの状態を確認することが出来ないので、
専用のデバッグツールをインストールします。
https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/related?hl=ja
これをインストールすると、このようにSTATEの値や、
ACTION実行時のログ、及び差分を表示することができるようになります。


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

今回使用したサンプルコードはGitHubに置いておきました。
NgRxが即使用できるシンプルなサンプルコードはなかなか見つからないので、
これを元に改版していただくと良いかと思います。
https://github.com/same-hack/NGRX-user.git
サンプルコードダウンロード&実行コマンド
git clone https://github.com/same-hack/NgRx-user.git
npm install
npm start
実行環境

うまく動かなければアップデートしてね!
まとめ

今回のSTORE操作の処理をわかりやすく図解しました。

筆者も理解するのに時間がかかった概念なので、
世界一わかりやすくまとめたつもりです。
NgRxを使用すると、ファイルがかなり多くなってしまい
混乱すると思いますが、こうして見てみると
やっていることは非常に理解しやすいと思います。
- NgRx・・・Angularで状態(STATE)管理をするためのシステム
- STORE・・・STATEを管理している空間。メモリ上の仮想DBのようなイメージ
- COMPONENT・・・画面描画&操作に関わる部分
- SELECTOR・・・データ取得(自動連携)の定義。getter()のようなもの
- ACTION・・・どこでなんの関数を使用しているかを記述
- REDUCER・・・STOREのSTATEを更新する。setter()のようなもの

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