NgRx v9に対応しました
NgRxはAngularアプリケーション用の状態管理ライブラリです。
本記事では、簡単なサンプルを通して@ngrx/storeと@ngrx/effectsの使い方を紹介します。
※ サンプルはNgRx v9系で書かれています
この記事の対象者
- Angularを利用している方
- Angular + RxJSで状態管理をしたい方
- NgRxを触ってみたい方
TL;DR
NgRxはいいぞ。
https://stackblitz.com/edit/ngrx-todo-v9
NgRxの概要
NgRxはAngular用の状態管理ライブラリです。NgRxを利用するとAngularを利用したアプリケーションでReduxのような状態管理ができます。
NgRxは下図のような構成になっており、Reduxと非常によく似ています。
https://ngrx.io/guide/store より
NgRxはRxJSベースで作られているため状態をストリームとして扱うことができます。
@ngrx/store
Angular用の状態管理ライブラリです。
ReduxのようにAction、Reducer、Storeの概念があります。
@ngrx/storeのコンセプトは以下のように紹介されています。
- Actionはコンポーネントやサービスからディスパッチされる一意のイベントを表す
- 状態はReducer(現在の状態と最新のActionから新しい状態を返す純粋関数)によって更新される
- Selectorは状態の一部を取り出したり組み合わせたりする純粋関数である
- 状態はStateとActionのObservableである
Store
からアクセスされる
@ngrx/effects
受け取ったActionを元にHTTP通信や別のActionを実行するなどの「副作用」を処理するライブラリ(redux-sagaやredux-thunkのようなもの)です。
@ngrx/effectsのコンセプトは以下のように紹介されています。
- Effectsはコンポーネントから副作用を分離し、コンポーネントをより綺麗にする
- EffectsはStoreにディスパッチされる全てのActionを監視し長期的に実行されるサービスである
- Effectsは
ofType
オペレータによって関心のあるActionを絞り込む- Effectsは新しいActionを返す同期または非同期なタスクを処理する
NgRxを使ってみよう
さっそくNgRxを使ってみましょう。
今回は簡単なTodoアプリに組み込むことを想定します。
インストール
npmからインストールしてください。
$ npm install @ngrx/store
$ npm install @ngrx/effects
ディレクトリ構成
NgRxを用いる場合は、下図のようにフィーチャ毎にstore
ディレクトリを用意し必要なファイルをまとめていくと良いと思います。
State
まずは状態と初期状態を定義しましょう。
NgRxでは、
-
Root state
ルートの状態、アプリケーション全体を通して利用される -
Feature state
各機能単位の状態、フィーチャモジュール内で利用される
と分けて扱うことが多いかと思います。
export interface State {
// ルートの状態
}
export const featureName = 'todo';
export interface State {
loading: boolean;
todos: Todo[];
error?: any;
}
export const initialState: State = {
loading: false,
todos: []
};
Todo
モデルは以下のようなものを想定しています。
export interface Todo {
id: string;
text: string;
}
今回の例では、アプリケーション全体の状態は以下のようになります。
{
todo: {
loading: false,
todos: [
{ id: '1', text: 'Learn Angular' },
{ id: '2', text: 'Learn RxJS' },
{ id: '3', text: 'Learn NgRx' },
],
error: {...}
}
}
Selector
Selectorは状態の一部を切り出すものです。
@ngrx/store
のcreateFeatureSelectorを使ってFeature stateを取得した後、createSelectorを使ってSelectorを作成します。
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { State, featureName } from '../state';
const getState = createFeatureSelector<State>(featureName);
export const getLoading = createSelector(getState, state => state.loading);
export const getTodos = createSelector(getState, state => state.todos);
createSelectorで作成したSelectorは自動的にメモ化されるため高速にアクセスできます。
Action
@ngrx/store
のcreateActionを使ってActionを作成します。
第1引数にはActionの名前を指定します。Actionの名前はGood Action Hygieneを参考に[Source] Event
の形式にすると良いと思います。
第2引数にはActionのペイロードを指定します。@ngrx/store
が提供するpropsを使うと簡潔に書けます。
import { createAction, props } from '@ngrx/store';
export const loadAll = createAction(
'[Todo Page] Load All',
props<{ offset?: number; limit?: number }>()
);
export const loadAllSuccess = createAction(
'[Todo API] Load All Success',
props<{ todos: Todo[] }>()
);
export const loadAllFailure = createAction(
'[Todo API] Load All Failure',
props<{ error: any }>()
);
ちなみに↓のように書くと、
export const createSuccess = createAction(
'[Todo API] Load All Success',
(payload: { todos: Todo[] }) => ({ payload })
);
action.payload
の形でペイロードを取り出すことができます。
Effects
Actionを受け取り、副作用を処理する部分です。
import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect, act } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, tap, concatMap, switchMap, catchError } from 'rxjs/operators';
import { TodoService } from '../../services';
import * as TodoActions from '../actions';
@Injectable()
export class TodoEffects {
constructor(
private actions$: Actions<TodoActions>,
private todoService: TodoService
) {}
loadAll$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadAll),
switchMap(({ offset, limit }) =>
this.todoService.loadAll(offset, limit).pipe(
map(result => TodoActions.loadAllSuccess({ todos: result })),
catchError(error => of(TodoActions.loadAllFailure({ error })))
)
)
)
);
}
マッピングオペレータについては、一覧読み込みなどの場合はswitchMap
、それ以外はconcatMap
を使うのをお勧めします(参考URL)。
TodoService
は以下のようなものを想定しています。
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Todo } from '../../models';
@Injectable({
providedIn: 'root'
})
export class TodoService {
constructor(private http: HttpClient) {}
findAll(offset?: number, limit?: number) {
const url = 'APIのアクセス先URL';
let params = new HttpParams();
params = offset ? params.set('offset', `${offset}`) : params;
params = limit ? params.set('limit', `${limit}`) : params;
return this.http.get<Todo[]>(url, { params });
}
}
新しいアクションを呼ばない場合は{ dispatch: false }
を渡しましょう。
@Injectable()
export class TodoEffects {
createSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(TodoActions.loadAllSuccess),
tap(action => {
console.log('Success!');
})
),
{ dispatch: false }
);
}
Reducer
@ngrx/store
のcreateReducerを使ってReducerを作成します。
Reducerでは各Actionに対して状態がどのように遷移するかをonを使って書いていきます。
import { Action, createReducer, on } from '@ngrx/store';
import { State, initialState } from '../state';
import * as TodoActions from '../actions';
export const reducer = createReducer(
initialState,
on(TodoActions.loadAll, state => {
return { ...state, loading: true };
}),
on(TodoActions.loadAllSuccess, (state, { todos }) => {
return { ...state, loading: false, todos: [...state.todos, ...todos] });
}),
on(TodoActions.loadAllFailure, (state, { error }) => {
return { ...state, loading: false, error };
}),
);
本記事では解説しませんが、このようなエンティティのCRUD操作を簡略化する@ngrx/entityというパッケージもあります。
Facade
FacadeはNgRxの処理を抽象化します。作成は任意ですが、Facadeのような中間層を設けることでコンポーネントがNgRxに強く依存することを防ぎ、将来的なライブラリの変更にも柔軟に対応できるようになると思います(参考URL)。
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Todo } from '../../models';
import * as TodoSelectors from './selectors';
import * as TodoActions from './actions';
import { TodoStoreModule } from './todo-store.module';
@Injectable({
providedIn: TodoStoreModule // 'root' でもOK
})
export class TodoFacade {
loading$ = this.store.pipe(select(TodoSelectors.getLoading));
todos$ = this.store.pipe(select(TodoSelectors.getTodos));
constructor(private store: Store) {}
loadAll(offset?: number, limit?: number) {
this.store.dispatch(TodoActions.loadAll({ offset, limit }));
}
}
モジュールの作成
NgRxではStoreModule.forFeature()を用いてRoot stateにFeature stateを追加できます。
StoreModuleの初期化はAppModule
内、またはストア用モジュールで行うと良いでしょう。
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { reducers, metaReducers } from './reducers';
@NgModule({
imports: [
StoreModule.forRoot(reducers, { metaReducers }),
EffectsModule.forRoot([]),
]
})
export class AppStoreModule {}
metaReducers
にはログ等の前処理を差し込むことができます。追加は任意です。
デフォルトでは、開発時に状態やActionのイミュータブルチェックが行われますが、runtimeChecks
を指定して設定を上書きすることもできます。この他にもActionがシリアライズ可能かどうかのチェックもあるので必要に応じて切り替えると良いでしょう。
StoreModule.forRoot(reducers, {
metaReducers,
runtimeChecks: {
strictStateSerializability: false, // デフォルト: false
strictActionSerializability: false, // デフォルト: false
strictActionImmutability: false, // デフォルト: true
strictStateImmutability: false, // デフォルト: true
strictActionWithinNgZone: false // デフォルト: false
}
})
続いてFeature state用のモジュールを作成し、ReducerとEffectsを登録します。
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { featureName } from './state';
import { reducer } from './reducers';
import { TodoEffects } from './effects';
@NgModule({
imports: [
StoreModule.forFeature(featureName, reducer),
EffectsModule.forFeature([TodoEffects])
]
})
export class TodoStoreModule {}
作成したTodoStoreModule
はTodoModule
にインポートしましょう。
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TodoStoreModule } from './store';
import { TodoComponent } from './todo.component';
@NgModule({
imports: [
CommonModule,
TodoStoreModule
],
exports: [TodoComponent],
declarations: [TodoComponent]
})
export class TodoModule {}
「状態をNgModuleとして扱える」と理解することがNgRxで状態を設計するコツです👍
AppModuleに登録
最後に、作成したモジュールをAppModule
にインポートしましょう。
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { AppStoreModule } from './app-store.module';
import { TodoModule } from './todo';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
AppStoreModule, // 必須
TodoModule, // ←Lazy Loadingしても良い
],
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: []
})
export class AppModule {}
これで状態管理の仕組みが整いました。早速コンポーネント内で使ってみましょう。
コンポーネント内で使う
Store
をコンポーネントにDIしましょう。
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
constructor(private store: Store) {}
}
状態の取得やActionのディスパッチを行います。
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import * as TodoSelectors from './store/todo/selectors';
import * as TodoActions from './store/todo/actions';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
this.todos$ = this.store.pipe(select(TodoSelectors.getTodos));
constructor(private todoService: TodoFacade) {}
ngOnInit() {
const offset = 0;
const limit = 10;
this.store.dispatch(TodoActions.loadAll({ offset, limit }));
}
}
テンプレート内でasync
パイプを使うと状態を簡単に取り出せます。
<ul>
<li *ngFor="let todo of todos$ | async">
{todo.text}
</li>
</ul>
Facadeを使う場合
作成したTodoFacade
をDIしましょう。
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
constructor(private todoService: TodoFacade) {}
}
状態の取得やActionのディスパッチはFacade経由で行います。
import { Component, OnInit } from '@angular/core';
import { TodoFacade } from './store';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
this.todos$ = this.todoService.todos$;
constructor(private todoService: TodoFacade) {}
ngOnInit() {
const offset = 0;
const limit = 10;
this.todoService.loadAll(offset, limit);
}
}
終わりに
NgRxを利用することで、AngularアプリケーションにReduxのような状態管理を組み込むことができます。Angularとの親和性が高いだけでなく、優秀な型推論や豊富な機能、テストユーティリティ、ドキュメントも充実しているので安心して使用できると思います。
「アプリケーションの規模が大きくなって状態管理つらい...」となったときにNgRxはきっと役に立つでしょう。
本記事を見て興味を持ったという方はぜひデモで動作確認してみてください。
https://stackblitz.com/edit/ngrx-todo-v9
テストコードを含む今回のサンプルのソースは以下からダウンロードできます。
https://github.com/puku0x/ngrx-todo
NgRxはまだ日本語の情報が少ないので「もっと良い使い方があるぞ!」という場合はコメントしていただければ幸いです。
参考
NgRx document
NgRx example application
Comprehensive Introduction to @ngrx/store
NgRx + Facades: Better State Management