TypeScript
angular
RxJS
ngrx
nrwl

はじめに

この記事はAngularアドベントカレンダー2017の12日目の記事です。

この記事はngrxについて興味があり、とりあえず概要を知りたい人を対象に書いています。
ngrxとはどういうものなのか、どんな機能があるのか、といった部分にフォーカスしてざっくり書いています。
各機能について深く掘り下げた内容は、また別の機会に個別に書くつもりです。

当記事はv4.1.1(現時点での最新版)を前提としており、基本的に全て公式リポジトリの内容を参考に書いています。
また、本記事中のサンプルコードなどはimportを省略していたりするので、そのままでは動きませんのでご注意ください。

ngrxとは

ngrxとはAngularアプリケーションの状態管理を適切に扱うためのライブラリ集です。
一言で簡潔に説明すると、AngularとRxJSに最適化したReduxです。

現時点で@ngrx/storeを中心とした5つのモジュールが公開されていて、今後も新しいモジュールが追加されていく予定です。
まだまだコントリビューターが少なく、ドキュメントなども未整備な部分が多い印象がありますが、非常に便利で強力な機能を提供してくれています。

ngrxを使う理由

個人的にngrxを使う理由は主に以下の2つが大きいです。

  • 開発者間での共通な設計思想
  • コンポーネントテストの容易性

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

Angularは状態管理の方法にデファクトスタンダードが無く、プロジェクトによって状態管理の方法が異なったり設計がバラバラなことが多いと思います。
どのサービスを作成したり、どこにモデルを集めるかなどのアプリケーション設計で開発者同士の意見が割れたりします。
これは非常に不毛で誰も幸せになれないので、頭のいい人が考えたngrxという設計にそのまま乗っかってしまおうという魂胆です。

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

コンポーネントが多くのServiceやRouterなどに依存していると、テストを書く負担が高くなります。
ngrxを使用するとコンポーネントの依存は基本的にStoreだけになるので、テストを書くのが非常に簡単になります。

ngrxの各モジュールについて

それではngrxにどんなモジュールがあるのか見ていきましょう。
現在npmパッケージとして公開されているのは下の5つになります。

モジュール名 概要
@ngrx/store Store, Action, Reducerなどの機能を提供するモジュール。
@ngrx/store-devtools Redux Devtools Extensionを使う為のモジュール。
@ngrx/router-store Routerのデータをngrxで扱う為のモジュール。
@ngrx/effects 副作用のある処理を扱う為のモジュール。
@ngrx/entity モデルのコレクション処理を扱い易くするモジュール。

個別に見ていきましょう。

@ngrx/store

@ngrx/storeは、ngrxの中心となるモジュールです。
ReduxのStore, Action, Reducerのコンセプトがそのまま使われています。Reduxを知っている人であれば迷わず使えるでしょう。

よくあるカウンターの実装のサンプルコードを見てみましょう。
まずReducerの定義は下のようになります。コードはだいぶ省略していますので、ここではあくまで雰囲気を掴むだけに留めてください。

counter.ts

import { Action } from '@ngrx/store';

export function counterReducer(state: number = 0, action: Action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;

    case 'DECREMENT':
      return state - 1;

    case 'RESET':
      return 0;

    default:
      return state;
  }
}

実際に作成したReducerを適用する為には、NgModuleにStoreModuleをimportします。
import方法はforRootforFeatureがあり、遅延ロードにも対応しています。

app.module.ts

import { NgModule } from '@angular/core'
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.forRoot({ counter: counterReducer })
  ]
})
export class AppModule {}

上記により、コンポーネント側でStoreが使用できます。
使い方はシンプルで、欲しいデータをselectするか、必要なアクションをdispatchするだけです。

component.ts

import { Store } from '@ngrx/store';

interface State {
  counter: number;
}

@Component({
  template: ''
})
export class MyComponent {
  counter: Observable<number>;

  constructor(private store: Store<State>) {
    this.counter = store.select('counter');
  }

  increment(){
    this.store.dispatch({ type: 'INCREMENT' });
  }

  decrement(){
    this.store.dispatch({ type: 'DECREMENT' });
  }

  reset(){
    this.store.dispatch({ type: 'RESET' });
  }
}

基本的な使い方は以上です。
@ngrx/storeはMemoizedSelectorという機能も提供していて、コンポーネント側で最適な変更検知が行えるようになっているのですが、この記事では割愛します。

@ngrx/store-devtools

このモジュールをimportするとRedux Devtools Extensionが使えます。
使い方はルートのモジュールにimportするだけで、非常にシンプルなモジュールです。

app.module.ts

import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
  imports: [
    StoreDevtoolsModule.instrument({
      // 何個前までのstateを保持するか
      maxAge: 25
    })
  ]
})
export class AppModule { }

@ngrx/router-store

このモジュールを使用すると、ngrxでAngularのRouterのデータを扱う事ができます。
具体的には、内部的にRouterのイベントを監視し、StoreにRouterEvent.idRouterStateSnapshotを管理してくれるReducerを提供してくれます。

使い方を見てみましょう。

app.module.ts

import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store';

@NgModule({
  imports: [
    StoreModule.forRoot({ routerReducer: routerReducer }),
    StoreRouterConnectingModule
  ],
})
export class AppModule { }

非常にシンプルで、reducerにrouterReducerを追加してStoreRouterConnectingModuleをimportするだけです。
これにより下のようなstateを扱うことができます。

interface RouterReducerState {
  state: RouterStateSnapshot;
  navigationId: number; // RouterEvent.id
}

RouterStateSerializer

routerReducerで渡されるRouterStateSnapshotは巨大でミュータブルなオブジェクトです。
その為、StoreDevtoolsやstore freezeなどの使用時に問題が出たりします。

@ngrx/router-storeはRouterStateSerializerという機能を提供していて、RouterStateSnapshotから必要な部分だけを抽出することができます。
サンプルコードを見てみましょう。

import { Params, RouterStateSnapshot } from '@angular/router';
import {
  StoreRouterConnectingModule,
  routerReducer,
  RouterReducerState,
  RouterStateSerializer
} from '@ngrx/router-store';

export interface CustomRouterState {
  url: string;
  queryParams: Params;
}

export class CustomSerializer implements RouterStateSerializer<CustomRouterState> {
  serialize(routerState: RouterStateSnapshot): CustomRouterState {
    return {
      url: routerState.url,
      queryParams: routerState.root.queryParams
    };
  }
}

@NgModule({
  imports: [
    StoreModule.forRoot({ routerReducer: routerReducer }),
    StoreRouterConnectingModule
  ],
  providers: [
    { provide: RouterStateSerializer, useClass: CustomSerializer }
  ]
})
export class AppModule { }

上記ではRouterStateSnapshotの中からURLとqueryParamsを取り出すRouterStateSerializerを使用しています。
個人的に問題点なのが、RouterStateSnapshotはツリー構造なので、必要なデータが毎回rootの位置にあるとは限らないという点です。
これについては自前でqueryParamsなどを探索する機能を作るしかなさそうです。

@ngrx/effects

@ngrx/effectsは、ReduxのMiddlewareのようにReducerで処理できない副作用のある処理を担当します。
例えばAPIリクエストを行って、成功か失敗のどちらかに処理が分岐するようなケースです。

Reduxのmiddlewareと違って、ngrxのEffectsはReducerで処理された後のアクションをsubscribeして、さらに追加の処理を加えます。

Effectsとして使用するクラスはただのサービスのクラスです。
例としてログイン用のAPIリクエストの一例を見てみましょう。

login.ts

import { Actions, Effect } from '@ngrx/effects';

@Injectable()
export class LoginEffects {
  // 'LOGIN'のアクションに対して、成功か失敗の副作用の処理を行う
  @Effect() 
  login$: Observable<Action> = this.actions$
    .ofType('LOGIN')
    .mergeMap((action) =>
      this.http.post('/login', action.payload)
        // ログイン成功時は'LOGIN_SUCCESS'のアクションをdispatchする
        .map(data => ({ type: 'LOGIN_SUCCESS', payload: data }))
        // ログイン失敗時は'LOGIN_FAILED'のアクションをdispatchする
        .catch(() => of({ type: 'LOGIN_FAILED' }))
    );

  constructor(
    private http: Http,
    private actions$: Actions
  ) {}
}

@ngrx/effectsから提供されるActionsをDIすることで、reducerで処理された後の全てのアクションをsubscribeできます。
上のコードでは、その中からofTypeLOGINのアクションだけを絞り込んでいます。

LOGINアクションに対して、副作用であるhttpリクエストを行い、成功か失敗かでそれぞれ新しいアクションをdispatchしています。
@Effectのデコレーターは、ngrxがStoreに内部的にアクションをdispatchさせるために必要です。

router.ts

import { Actions, Effect } from '@ngrx/effects';

@Injectable()
export class RouterEffects {
  @Effect({ dispatch: false })
  login$: Observable<Action> = this.actions$
    .ofType('NAVIGATE')
    .do((action) => this.router.navigateByUrl(action.payload));

  constructor(
    private http: Http,
    private actions$: Actions,
    private router: Router,
  ) {}
}

@Effect({ dispatch: false })と設定すれば、Storeにアクションをdispatchしません。
上の例ではNAVIGATEのアクションに対して副作用としてページ遷移を行っていますが、新しいアクションをdispatchしていません。

app.module.ts

@NgModule({
  imports: [
    EffectsModule.forRoot([ LoginEffects ])
  ]
})
export class AppModule { }

作成したEffectsを実際に使用する際はNgModuleにimportします。
こちらもStoreModuleと同じく、forRootforFeatureがあり、遅延ロードにも対応しています。

@ngrx/entity

@ngrx/entityはReducerでよくあるモデルのコレクション操作を簡潔に書けるようにしてくれる各機能を提供してくれます。
簡単にサンプルコードを見ていきましょう。

EntityAdapter

@ngrx/entityでは、EntityAdapterというものを使用してReducerの処理を簡潔化します。
だいぶ省略していますが、@ngrx/entityを使用すると下のような雰囲気のReducerを書けます。

reducer.ts

import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';


interface User {
  id: number;
}

// 指定のモデルを@ngrx/entityで扱えるようにするadapterを作成
export const adapter: EntityAdapter<User> = createEntityAdapter<User>();

export const initialState: EntityState<User> = adapter.getInitialState();

// EntityAdapterを使用したCRUDな処理
export function reducer(state = initialState, action: any): EntityState<User> {
  switch (action.type) {
    case ADD_USER: {
      return adapter.addOne(action.payload.user, state);
    }
    case ADD_USERS: {
      return adapter.addMany(action.payload.users, state);
    }
    case UPDATE_USER: {
      return adapter.updateOne(action.payload.user, state);
    }
    case UPDATE_USERS: {
      return adapter.updateMany(action.payload.users, state);
    }
    case DELETE_USER: {
      return adapter.removeOne(action.payload.id, state);
    }
    case DELETE_USERS: {
      return adapter.removeMany(action.payload.ids, state);
    }
    case LOAD_USERS: {
      return adapter.addAll(action.payload.users, state);
    }
    case CLEAR_USERS: {
      return adapter.removeAll({ ...state });
    }
    default: {
      return state;
    }
  }}

EntityState

実際に上でreduceされてStoreに反映されるEntityStateは、以下のようなインターフェースになります。

interface EntityState<T> {
  ids: string[] | number[];
  entities: {[id: string ]: T} | {[id: number ]: T};
}

EntitySelectors

@ngrx/entityはEntityStateから各データを取得するセレクターの処理も提供してくれるので、ユーザーはEntityStateの中身を意識することはありません。
EntitySelectorsは下の4種類があります。

// idの配列を取得
selectIds
// idのキーに対するデータの、key/value型オブジェクトを取得
selectEntities
// データの配列を取得
selectAll
// 件数を取得
selectTotal

長くなるのでこの記事でこれ以上は触れませんが、ソート処理に対応していたりもします。
また、現在upsert処理のプルリクエストも作成されているので、より便利になりそうです。

今後ngrxに入りそうなモジュール

おまけです。
現在のngrxとして公開されていませんが、今後入ってくることが期待される2つのモジュールを紹介します。

モジュール名 概要
@ngrx/codegen ngrxのテンプレートファイルを生成するモジュール。
@ngrx/db IndexedDBをRxJSで扱う為のモジュール。

@ngrx/codegen

@ngrx/codegenはngrxのテンプレートファイルを自動生成してくれるモジュールです。
現在はプロトタイプが作成されている段階で、正式リリースの時期の目処は立っていない状況です。

まずはActionのInterfaceから、EnumやUnionTypeなどを生成してくれる機能を検討しているようです。
実際に現在のプロトタイプを試してみると以下のような結果が得られました。

生成もとファイル

import { Action } from '@ngrx/store';

export interface AddAction extends Action {
  type: '[Math] Add';
  amount: number;
}

export interface SubtractAction extends Action {
  type: '[Math] Subtract';
  amount: number;
}

export interface ComputeErrorAction extends Action {
  type: '[Math] Error';
  errors?: any[];
}

生成されるファイル

import { AddAction, SubtractAction, ComputeErrorAction } from './test';

export enum MathActionType {
    Add = "[Math] Add",
    Subtract = "[Math] Subtract",
    Error = "[Math] Error"
}

export type MathActions = AddAction | SubtractAction | ComputeErrorAction;

export type MathActionLookup = {
    '[Math] Add': AddAction;
    '[Math] Subtract': SubtractAction;
    '[Math] Error': ComputeErrorAction;
};

export function createAddAction(amount: AddAction["amount"]): AddAction {
    return { type: MathActionType.Add, amount };
}

export function createSubtractAction(amount: SubtractAction["amount"]): SubtractAction {
    return { type: MathActionType.Subtract, amount };
}

export function createComputeErrorAction(errors?: ComputeErrorAction["errors"]): ComputeErrorAction {
    return { type: MathActionType.Error, errors };
}

個人的にとても楽しみにしているモジュールです。
Reducerなども生成できるようになったらとてもうれしいですね。

@ngrx/db

こちらは他モジュールと違ってまだ本リポジトリ(platform)で開発されていません。
Issueは作成されていて、platformへの仲間入りを目指しているようです。

このモジュールはIndexedDBをRxJSベースで扱えるようにしてくれるライブラリで、今のところngrxの他モジュールとの関連はなく単独で使用できます。
readmeにはまだ使用するなと書いてありますが、 npm install @ngrx/db でインストール可能で、ngrxのサンプルアプリでも使用されています。

簡単に使い方を紹介します。

DB初期設定

設定はシンプルにimport時に指定のスキーマを渡します。

const schema = {
  version: 1,
  name: 'db_name',
  stores: {
    // 各tableの設定
    'todos': {autoIncrement: true},
    'users': {autoIncrement: true},
  }
};

@NgModule({
  imports: [
    DBModule.provideDB(schema),
  ],
})
export class AppModule { }

各クエリ処理

現時点では削除系の処理は未実装のようです。
まだまだ開発中のようで、おそらくインターフェースも大きく変わると思われます。
下記内容はこちらを参考にしています。

import { Database } from '@ngrx/db';


@Injectable()
export class SampleService {
  // DB開始処理
  constructor(private db: Database) {
    this.db.open('db_name');
  }

  // 新規作成
  create() {
    this.db.insert('todos', [{name: 'todo1'}, {name: 'todo2'}])
      .toArray()
      .subscribe((results) => {
        expect(results[0]).toEqual({$key: 1, name: 'todo1'});
        expect(results[1]).toEqual({$key: 2, name: 'todo2'});
      });
  }

  // 更新
  update() {
    this.db.insert('todos', [{$key: 1, name: 'todo1++'}, {$key: 2, name: 'todo2++'}])
      .toArray()
      .subscribe((results: any) => {
        expect(results[0]).toEqual({$key: 1, name: 'todo1++'});
        expect(results[1]).toEqual({$key: 2, name: 'todo2++'});
      });
  }

  // 全件取得
  readAll() {
    this.db.query('todos').toArray()
      .subscribe((records) => {
        // todosの配列
        expect(Array.isArray(records)).toBeTruthy();
      });
  }

  // 指定のIDで取得
  readOne() {
    this.db.get('todos', 1)
      .subscribe((record) => {
        expect(record).toEqual({name: 'todo1'});
      });
  }
}

こちらもとても楽しみにしているモジュールです。
@ngrx/entityと組み合わせたらとても便利に使えるのではないかと思います。

おわりに

以上、いかがでしょうか。
当初の想定よりも長くなってしまいましたが、ngrxの雰囲気は紹介できたかと思います。
今回はざっくり紹介でしたが、各モジュールについてもっと掘り下げるべき内容が多くあるので、これについてはまたの機会に書いていこうと思います。

ngrxの実装を読んでいると非常に勉強になることが多くあります。
platformのリポジトリ内にあるサンプルアプリもよく出来ているので、ngrxを勉強したい人はまずそこから触ってみるといいと思います。

明日は@nacikaさんです。