JavaScript
Angular
状態管理
redux
ngrx

Angularのngrxを使って状態管理を行う(理論編)


この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。

前記事:Angular+Firebase ユーザー設計とデータの保護

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



この記事で行うこと

前回の記事ではユーザー設計とデータの保護を扱いました。

ここからAngularの@ngrxというライブラリを使って、状態管理の理論、実装を3回にわけて行っていきます。

本記事ではまず、状態管理の理論について学習します。


@ngrxとは

@ngrxはReduxというライブラリに影響を受けて作成されたAngularの公式ライブラリです。Actions、Dispatcher、Reducer、State、ViewというReduxのデータフロー構成を、RxJSとクラスを使ってAngular流に再現しています。

Reduxのデータフロー


  1. View(Component)でユーザーがクリックするなどの動作でActionを発行

  2. DispatcherがActionをStoreに渡す

  3. Actionの結果をReducerで元のStateと合成する

  4. stateをViewに返して、Viewを更新する

Redux図 (1).png

また、@ngrxngrx/platformで開発が進められており、2018/10月現在では下記ライブラリが利用できます。

ライブラリ名
概要

@ngrx/store
Reduxに影響を受けた、RxJSベースのAngularアプリケーション用状態管理ライブラリ

@ngrx/effects
発行されたActionを元にWebAPIにアクセスするといった「副作用」を処理するライブラリ

@ngrx/router-store
Angular Routerと@ngrx/storeを連携させるライブラリ

@ngrx/store-devtools
リアルタイムでストアの状態を把握するデバッグ用ライブラリ

@ngrx/entity
エンティティ(複数配列データ)の管理ができるライブラリ

@ngrx/schematics
ngrxのコードジェネレーターライブラリ


参考

ngrx紹介


本題


なぜngrxを使う必要があるのか

以前の記事で、Angularのデータ管理にはRxJSを使用するということを書きました。あるコンポーネントで利用したデータをほかのコンポーネントでも利用したい場合、RxJSのストリーム(Observable)にデータを流すことで実現できます。

Redux図 (2).png

規模の小さいアプリケーションであれば、上記のような構成でもそれほど問題になりません。しかし、アプリケーションの規模が大きくなってObservableの数が増え始めると、それぞれのObservableの配線が複雑になって、Stateがどのような状況になっているかが不明瞭になっていきます。

Redux図 (3).png

状態管理が複雑になってしまうと、何か不具合があったときにどのObservableによるものなのかが判断しづらく、解消に時間がかかってしまったり、不要なObservableがメモリを圧迫してパフォーマンスに影響を与えてしまうようなことが考えられます。

この事態を避けるために有効な手段が@ngrxライブラリの導入です。状態管理に一定の規則性を与え、1つの大きなオブジェクト(ステートツリー)で管理することで上記の懸念を解消できます。

Redux図 (5).png

また、@ngrxの導入には、複雑性の解消以外にも次のようなことが期待できます。


  • 開発者間での共通な設計思想

  • コンポーネントテストの容易性

これらの有効性についてはこちらの記事を参照してください。


ngrxの流れを見る

具体的な実装方法については次回以降の記事で扱うので、ここでは各構成の役割を流れに沿って確認していきます。


①View → Dispatcher

Redux図 (13).png

まずはView(コンポーネント)でActionを作成し、Dispatcherに渡します。


session.action.ts

export enum SessionActionTypes {

LoadSessions = '[Session] Load',
LoginSessions = '[Session] Login',
LogoutSessions = '[Session] Logout'
}

export class LoadSessions implements Action {
readonly type = SessionActionTypes.LoadSessions;
}

export class LoginSessions implements Action {
readonly type = SessionActionTypes.LoginSessions;
}

export class LogoutSessions implements Action {
readonly type = SessionActionTypes.LogoutSessions;
}

export type SessionActions =
LoadSessions
| LoginSessions
| LogoutSessions;



app.component.ts

export class AppComponent {

constructor(private store: Store<fromCore.State>) {
this.store.dispatch(new LoadSessions());
}

login() {
this.store.dispatch(new LoginSessions());
}

logout() {
this.store.dispatch(new LogoutSessions());
}

}


ActionはView上で生じる操作(クリック、スクロール)や、データの読み込みといった、データ(State)の変化を生じさせるトリガーを定義したものです。

ここではセッションデータの読み込み、ログイン、ログアウトといった操作を定義し、dispatch()の引数にすることでReducerにActionを渡しています。


②Dispatcher → Reducer → State

Redux図 (14).png

続いてDispatcherで受けたActionをStoreにあるReducerに渡します。


session.reducer.ts

/**

* State
*/

export interface State {
session: { login: boolean };
}

export const initialState: State = {
session: { login: false };
};

/**
* Reducer
*/

export function reducer(
state = initialState,
action: SessionActions
): State {
switch (action.type) {
case SessionActionTypes.LoadSessions: {
return { ...state };
}

case SessionActionTypes.LoginSessions: {
return { session: { login: true } };
}

case SessionActionTypes.LogoutSessions: {
return { session: { login: false } };
}

default:
return state;
}
}

export const getSession = (state: State) => state.session;


Reducerでは最初にStateの型と初期値(initialState)を定義します。

初めてdispatch()された時は、このinitialStateがstateとして流れてきます。

reducerクラスには現在のstateとDispatcherで指定されたactionが引数として渡され、actionのタイプによって異なる処理が加えられます。

流れてきたActionのタイプがLoginSessionsだった場合、session stateは{ login: true }に上書きされ、LogoutSessionsだった場合は{ login: false }に上書きされます。

getSession()は取得したいstateを指定するためのメソッドです。Viewからstateを指定するときは、このメソッドを使うことになります。


③State → View

Redux図 (15).png

最後は②で上書きしたStateをViewに渡して反映させます。


header.component.ts

export class HeaderComponent implements OnInit {

public session$: Observable<{login: boolean}>;

constructor(private store: Store<fromCore.State>) {
this.session$ = this.store.select(fromCore.getSession);
}

ngOnInit() {
}
}


this.store.select()で取得したいstateを指定し、session$に格納しています。

これにより他のコンポーネントで更新されたStateを、リアルタイムで反映することができるようになります。

@ngrxの基本的なデータの流れは以上です。

次は非同期通信で外部からデータを取得した場合について考えます。


Effectで非同期のデータを扱う

非同期通信でデータを取得する場合、どのような流れになるのかを図示してみます。

Redux図 (7).png

最初の図に@Effectという概念が追加されました。非同期通信時は、この@Effectを使ってデータの取得とStateの更新を行います。

@Effectは特定のActionがDispatcherに渡された瞬間から開始され、非同期通信の結果が取得でき次第、その結果を再びDispatcherに渡します。もちろん、その結果には通信の成功と失敗の両方がありますので、それに合わせて新しいActionを用意する必要があります。


このときの処理の流れは、こちらを参照するとわかりやすいです。


@Effect上記のをコードに反映すると次のようになります。


session.action.ts

export enum SessionActionTypes {

LoadSessions = '[Session] Load',
LoadSessionsSuccess = '[Session] Load Success',
LoadSessionsFail = '[Session] Load Fail',
LoginSessions = '[Session] Login',
LogoutSessions = '[Session] Logout'
}

export class LoadSessions implements Action {
readonly type = SessionActionTypes.LoadSessions;
}

export class LoadSessionsSuccess implements Action {
readonly type = SessionActionTypes.LoadSessionsSuccess ;

constructor(public payload: { session: { login: boolean } }) {}
}

export class LoadSessionsFail implements Action {
readonly type = SessionActionTypes.LoadSessionsFail ;

constructor(public payload?: { error: any }) {}
}

export class LoginSessions implements Action {
readonly type = SessionActionTypes.LoginSessions;
}

export class LogoutSessions implements Action {
readonly type = SessionActionTypes.LogoutSessions;
}

export type SessionActions =
LoadSessions
| LoadSessionsSuccess
| LoadSessionsFail
| LoginSessions
| LogoutSessions;



session.reducer.ts

/**

* Reducer
*/

export function reducer(
state = initialState,
action: SessionActions
): State {
switch (action.type) {
case SessionActionTypes.LoadSessions: {
return { ...state };
}

case SessionActionTypes.LoadSessionsSuccess: {
return { session: action.payload.session };
}

case SessionActionTypes.LoadSessionsFail: {
if (action.payload) {
console.log(action.payload.error);
}
return { ...state };
}

case SessionActionTypes.LoginSessions: {
return { session: { login: true } };
}

case SessionActionTypes.LogoutSessions: {
return { session: { login: false } };
}

default:
return state;
}
}



session.effect.ts

export class ChatEffects {

constructor(private actions$: Actions,
private afAuth: AngularFireAuth) {
}

@Effect()
loadSession$: Observable<Action> =
this.actions$.pipe(
ofType<LoadSessions>(SessionActionTypes.LoadSessions),
map(action => action),
switchMap(() => {
return this.afAuth.authState
.pipe(
take(1),
map( result => {
const login_state = { login: (!!result) };
return new LoadSessionsSuccess({ session: login_state })
}),
catchError( err => new LoadSessionsFail({ error: err }))
);
})
);
}


ActionとReducerにLoadSessionsSuccessLoadSessionsFailを加え、それぞれの初期値と返り値を定義しています。

Effectを使う場合は、まず@Effect()デコレータを宣言します。ofType()で対象となるActionを指定し、そのActionがdispatch()されると同時に処理が始まります。

今回はthis.afAuth.authState()でログイン状況を問合せ、その結果をLoadSessionsSuccess()でDispatcherに渡しています。そのデータはその後Reducerに渡され、現在のstateを上書きしてまたView(コンポーネント)へと渡されていきます。


まとめ

@ngrxはReduxとRxJSの両方の知識が必要となり、習得にはある程度時間がかかるかと思われます。

しかし、SPAを始めとした近年のWEBアプリケーションでは、クライアントサイトでのデータ処理が増えているため、体系化した状態管理のしくみが求められています。今後、新しいWEBアプリケーションを構築する、もしくは既存アプリケーションの状態管理を体系化するといったことを予定している場合は、@ngrxの導入を検討してみてください。

ここまで、@ngrxの概念と基本的な構成を扱いました。次回の記事では、具体的な実装方法について取り上げていきます。