【 Ionic + @ngrx 入門】 Ionic + @ngrx/store でカウンターアプリを作ってみる

AngularのReactiveライブラリである@ngrxに手を出したので、まずはIonic + @ngrx/storeでカウンターアプリを作ってみました。ある程度Fluxアーキテクチャ周りの知識が必要になります。@ngrx/storeのバージョンは4.0.0です。

@ngrx/storeについてざっくりと

@ngrx/storeはGithub

RxJS powered state management for Angular applications, inspired by Redux

とあるように、Reduxでやるような状態管理をAngularが使用しているRxJSの力を使って実現しましょうというものです。ということでRedux同様、Store, Reducer, Actionでもって状態管理をします。ReduxではMiddleware等で実現していた非同期処理は、@ngrxでは@ngrx/effectsで処理されます。

Ionicで新規プロジェクトの作成

まずはIonicで新規プロジェクトを作成します。Ionicをすでにインストールしている場合はionic, cordovaのインストールは必要ありません。npmからyarnに乗り換えた人は適宜読み替えてください。

# Ionic, Cordovaのインストール
npm install -g ionic cordova
 
# Ionicのblankテンプレートを使ってcounterという名前のアプリを作成
ionic start counter blank
 
# counterディレクトリが作成されるのでそこに移動
cd counter
 
# @ngrx/storeをインストール
npm install @ngrx/store
 
# ローカルサーバを立ち上げる
ionic serve

ちなみにインストール先でionic infoして得られる環境情報は以下の通りです。

cli packages:
 
    @ionic/cli-plugin-ionic-angular : 1.4.0 (...)
    @ionic/cli-utils                : 1.6.0 (...)
    ionic (Ionic CLI)               : 3.6.0 (...)
 
local packages:
 
    @ionic/app-scripts : 2.1.3
    Ionic Framework    : ionic-angular 3.6.0
 
System:
 
    Node       : v8.2.1
    OS         : macOS Sierra
    Xcode      : Xcode 8.3.3 Build version 8E3004b
    ios-deploy : 1.9.1
    ios-sim    : 6.0.0
    npm        : 5.3.0

Stateがプリミティブ型の例

@ngrxのドキュメントページに載っている、stateがプリミティブ型であるような一番簡単な例から始めます。

State/Action/Reducerの定義

簡単なカウンターアプリなのでディレクトリ構成をどうするかは特に考えず、以下の内容のcounter.tsファイルをsrc/appに追加します。

// src/app/counter.ts
 
import { Action } from '@ngrx/store';
 
/**
 * actionのタイプを定義
 */
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
 
/**
 * stateの初期状態を定義
 */
const initialState: number = 0;
 
/**
 * reducerの定義
 */
export function reducer(state: number = initialState, action: Action): number {
  switch (action.type) {
    case INCREMENT:
    return state + 1;
 
    case DECREMENT:
    return state - 1;
 
    case RESET:
    return 0;
 
    default:
    return state;
  }
}

reducer関数はReduxと同様に初期状態とactionを引数にとります。関数内ではaction.typeに応じてswitchで処理を分けます。ここではstateを1だけ増やす・1だけ減らす・0にリセットするの3つだけです。それ以外はstateをそのまま返します。カウンターアプリのように簡単な場合だとpayloadの出番はありませんが、実用的なアプリになるとstate.payloadを新しい状態の一部として返します。

AppModuleにStoreを登録

作成したreducerをsrc/app/app.module.tsで読み込み、StoreModule.forRootで登録します。

// src/app/app.module.ts
 
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
 
import { StoreModule } from '@ngrx/store';
import { reducer } from './counter';
 
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
 
@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    StoreModule.forRoot({counter: reducer})
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

ここではStoreModule.forRoot({counter: reducer})とすることで、stateの中で、読み込んだreducerで管理できる部分にcounterという名前をつけています。コンポーネントからはstore.select('counter')でこのstate(今回はプリミティブな数字)を取得することができるようになります。

Homeコンポーネントに実装

コンポーネントからstoreを利用する準備ができたので、Homeコンポーネントに諸々の処理を書いていきます。

// src/pages/home/home.ts
 
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
 
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
 
// counter.tsからactionを読み込む
import { INCREMENT, DECREMENT, RESET } from '../../app/counter';
 
/**
 * stateのためのインターフェースを定義
 * StoreModule.forRootで渡したオブジェクトのプロパティ名と同じ
 */
interface AppState {
  counter: number;
}
 
@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  // カウンターを保持するための内部変数
  count$: Observable<number>;
 
  constructor(public navCtrl: NavController, private store: Store<AppState>) {
    // store.selectでstoreに保存されているcounterを取得
    this.count$ = store.select('counter');
  }
 
  increment() {
    this.store.dispatch({ type: INCREMENT });
  }
 
  decrement() {
    this.store.dispatch({ type: DECREMENT });
  }
 
  reset() {
    this.store.dispatch({ type: RESET });
  }
 
}

home.htmlにはボタンとcount$を表示する要素を追加します。

<!-- src/pages/home/home.html -->
 
<ion-header>
  <ion-navbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-navbar>
</ion-header>
 
<ion-content padding>
  <div class="wrapper">
    <span>{{ count$ | async }}</span>
    <div>
      <button ion-button (click)="increment()"></button>
      <button ion-button (click)="reset()">Reset Counter</button>
      <button ion-button (click)="decrement()"></button>
    </div>
  </div>
</ion-content>

パイプを通してasyncに渡すことで、observableな非同期オブジェクトをそのままテンプレートに表示させています。

スタイルは適当につけておきます。

// src/pages/home/home.scss
 
page-home {
  .wrapper {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    height: 100%;
    & > span {
      display: block;
      font-size: 50px;
    }
  }
}

ローカルサーバで確認

ionic serve

でローカルサーバを立ち上げてhttp://localhost:8100/にアクセスし、こんな感じでアプリが表示されれば成功です。


 

複数のreducerに対応させる

ここまではreducerが1つだけのシンプルなものでしたが、実際のアプリではreducerが複数存在します。ということでそれに対応するためにコードを少し修正します。

reducers.tsの作成

まずは以下の内容のsrc/app/reducers.tsを作成します。

// src/app/reducers.ts
 
import {
  ActionReducerMap,
  createSelector,
  createFeatureSelector,
} from '@ngrx/store';
 
import * as fromCounter from './counter';
 
export interface AppState {
  counter: number;
}
 
export const reducers: ActionReducerMap<AppState> = {
  counter: fromCounter.reducer
};

先ほどsrc/pages/home/home.tsで定義していたAppStateをこちらに移しています。また、@ngrx/storeのActionReducerMapとしてreducersを定義しています。ここにcounter.tsから読み込んだreducerを登録します。
2つ以上のreducerを登録するときには

...
import * as fromSomeOther from './some-other';
 
...
export const reducers: ActionReducerMap<AppState> = {
  counter: fromCounter.reducer,
  someOther: fromSomeOther.reducer
};

のようにreducersの中に追加していきます。

app.module.tsでreducers.tsの読み込み

先ほどStoreModule.forRoot({counter: reducer})としてreducerを登録していましたが、今度はreducers.tsを読み込んでreducersを丸ごとStoreModule.forRootに渡します。

// src/app/app.module.ts
 
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
 
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
 
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
 
@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    StoreModule.forRoot(reducers),
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

home.tsの修正

あとはhome.tsの修正です。

// src/pages/home/home.ts
 
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
 
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
 
import { INCREMENT, DECREMENT, RESET } from '../../app/counter';
import { AppState } from '../../app/reducers';
 
@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  count$: Observable<number>;
 
  constructor(public navCtrl: NavController, private store: Store<AppState>) {
    this.count$ = store.select('counter');
  }
 
  increment() {
    this.store.dispatch({ type: INCREMENT });
  }
 
  decrement() {
    this.store.dispatch({ type: DECREMENT });
  }
 
  reset() {
    this.store.dispatch({ type: RESET });
  }
 
}

変更点はhome.ts内で定義していたinterface AppState ...を削除して、代わりにreducers.tsからAppStateを読み込んでいます。これで先ほどと同様にカウンターアプリが動作することが確認できます。

Meta Reducersを追加してstateとactionのログをとってみる

複数のreducerに対応させたときに、アプリ内のreducerをreducersとして一つにまとめました。ここではStoreModule.forRootにmeta reducersを追加して、stateとactionのログをとってみます。手を加えるのはapp.module.tsです。

// src/app/app.module.ts
 
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
 
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
 
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
 
function debug(reducer) {
  return function(state, action) {
    const nextState = reducer(state, action);
    console.group(action.type);
    console.log(`%c prev state`, `color: #9E9E9E; font-weight: bold`, state);
    console.log(`%c action`, `color: #03A9F4; font-weight: bold`, action);
    console.log(`%c next state`, `color: #4CAF50; font-weight: bold`, nextState);
    console.groupEnd();
    return nextState;
  }
}
 
const metaReducers = [debug];
 
@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    StoreModule.forRoot(reducers, { metaReducers })
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

新たにdebug関数を定義してmetaReducers配列に渡し、StoreModule.forRootのオプションで登録しています。

metaReducerはreducerを引数にとり、新たなreducerを返す関数として定義します。reducerを返すため、return function(...)の引数は当然(state, action)です。このstateがreducerが実行される前のstateです。ここではreducerを実行後のstateをnextStateとし、
– reducerが実行される前のstate
– ディスパッチされたaction
– reducerが実行された後のstate
をコンソールに表示しています。reducerはstateを返すため、最後にはreturn nextStateで新たなstateを返しています。

@ngrxでは自分でわざわざロガーを登録しなくても、@ngrx/store-devtoolsという開発者用のデバッグツールが用意されています。使い方は簡単で、

npm install @ngrx/store-devtools

でインストールした後、

// src/app/app.module.ts
 
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
 
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { reducers } from './reducers';
 
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
 
@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument()
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

のように、imports配列にStoreDevtoolsModule.instrument()を追加するだけです。あとはブラウザにRedux DevTools Extensionをインストールすれば、Redux DevToolsで状態遷移を追いかけることができます。

Actionにpayloadをもたせてタイプ付けをする

簡単なカウンターアプリでは必要ありませんが、多くの場合ではactionをディスパッチする際にpayloadをもたせてreducerに情報を渡します。ところが@ngrx/storeのActionインターフェースはtypeプロパティしかもっていないため、これだけだとpayloadをもたせることができません。

また複数のreducerを実装するようになると、actionの衝突を避ける必要が出てきます。ということで次はactionにpayloadをもたせてタイプ付けします。

Actionの実装

まずは以下の内容のsrc/app/actions.tsファイルを作成します。

// src/app/actions.ts
 
import { Action } from '@ngrx/store';
 
export const INCREMENT  = '[Counter] Increment';
export const DECREMENT  = '[Counter] Decrement';
export const RESET      = '[Counter] Reset';
 
export class Increment implements Action {
  readonly type = INCREMENT;
}
 
export class Decrement implements Action {
  readonly type = DECREMENT;
}
 
export class Reset implements Action {
  readonly type = RESET;
 
  constructor(public payload: number) {}
}
 
export type All
  = Increment
  | Decrement
  | Reset;

インターフェースとして定義されているActionに対してIncrementDecrementResetクラスを実装しています。最後にはTypeScriptのUnion type(直和型)としてすべてのactionをexportしています。

今まではResetでカウンターを0にリセットしていましたが、それでは面白くないので、Resetクラスのconstructorでpayloadをもたせています。これでactionをディスパッチする際にpayloadを追加できるようになります。

Reducerの修正

次にcounter.tsを修正します。

// src/app/counter.ts
 
import { Action } from '@ngrx/store';
import * as CounterActions from './actions'
 
export type Action = CounterActions.All;
 
const initialState: number = 0;
 
export function reducer(state: number = initialState, action: Action): number {
  switch (action.type) {
    case CounterActions.INCREMENT:
      return state + 1;
 
    case CounterActions.DECREMENT:
      return state - 1;
 
    case CounterActions.RESET:
      return action.payload;
 
    default:
      return state;
  }
}

今までは@ngrx/storeからActionを読み込んでいましたが、ここではactions.tsからActionを呼んでいます。またリセット時に0を返していましたが、action.payloadが返るように変更しています。

home.tsの修正

最後にhome.tsの修正です。

// src/pages/home/home.ts
 
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
 
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
 
import * as Counter from '../../app/actions';
import { AppState } from '../../app/reducers';
 
@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  count$: Observable<number>;
 
  constructor(public navCtrl: NavController, private store: Store<AppState>) {
    this.count$ = store.select('count');
  }
 
  increment() {
    this.store.dispatch(new Counter.Increment());
  }
 
  decrement() {
    this.store.dispatch(new Counter.Decrement());
  }
 
  reset() {
    this.store.dispatch(new Counter.Reset(3));
  }
 
}

actionをディスパッチする際にactions.tsで定義したクラスを生成しています。ここではResetに3を渡しているので、リセットボタンをクリックするとカウンターが3に初期化されるのがアプリで確認できます。

Stateをプリミティブ型からオブジェクト型にする

多くのアプリではstateはnumberやstringなどのプリミティブ型ではなく、それらを含むオブジェクト型です。ということでstateをnumberからobjectにしてみます。

counter.tsの修正

まずはcounte.tsです。

// src/app/counter.ts
 
import * as CounterActions from './actions'
 
export type Action = CounterActions.All;
 
export interface State {
  count: number;
}
 
const initialState: State = {
  count: 0
};
 
export function reducer(state: State = initialState, action: Action): State {
  switch (action.type) {
    case CounterActions.INCREMENT:
      return {count: state.count + 1};
 
    case CounterActions.DECREMENT:
      return {count: state.count - 1};
 
    case CounterActions.RESET:
      return {count: action.payload};
 
    default:
      return state;
  }
}
 
export const getCount = (state: State) => state.count;

export interface Stateが加わっています。ここでstateをオブジェクトとして定義しています。中にはnumber型のcountプロパティをもたせています。この変更に伴い、initialStateやreducerの返り値の型がnumberからStateに変更されます。

また、一番下にcountプロパティを抜き出すための関数を新たに定義しています。あとでcreateSelectorにこの関数を渡します。

reducers.tsの修正

次はreducers.tsです。

// src/app/reducers.ts
 
import {
  ActionReducerMap,
  createSelector,
  createFeatureSelector,
} from '@ngrx/store';
 
import * as fromCounter from './counter';
 
export interface AppState {
  counter: fromCounter.State;
}
 
export const reducers: ActionReducerMap<AppState> = {
  counter: fromCounter.reducer
};
 
export const getCounterState = createFeatureSelector<fromCounter.State>('counter');
export const getCounterCount = createSelector(getCounterState, fromCounter.getCount);

AppStateの中身がcounter: numberからcounter: fromCounter.Stateに変更されています。また、最後の2行でcountプロパティを取得するためのgetCounterCountを定義しています。そのために、まずはstateの中でcounter部分だけを取り出したgetCounterStatecreateFeatureSelectorを使って定義しています。そしてcreateSelectorの第一引数にそれを、第二引数にcounter.tsで定義したgetCount関数を渡しています。これでgetCounterCountを使ってcountプロパティの値をコンポーネント側で参照することができます。

home.tsの修正

最後はhome.tsです。

// src/pages/home/home.ts
 
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
 
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
 
import * as fromCounter from '../../app/actions';
import * as fromRoot from '../../app/reducers';
 
@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  count$: Observable<number>;
 
  constructor(public navCtrl: NavController, private store: Store<fromRoot.AppState>) {
    this.count$ = store.select(fromRoot.getCounterCount);
  }
 
  increment() {
    this.store.dispatch(new fromCounter.Increment());
  }
 
  decrement() {
    this.store.dispatch(new fromCounter.Decrement());
  }
 
  reset() {
    this.store.dispatch(new fromCounter.Reset(3));
  }
 
}

constructorに渡されるstoreの型がfromRoot.AppStateに、store.selectの引数に渡されるのが’counter’からfromRoot.getCounterCountに変更されています。

以上の修正を加えることで、アプリの挙動はまったく同じままでstateをプリミティブなnumber型からオブジェクトに変更することができます。先ほど導入したmetaReducers、もしくはStoreDevtoolsModuleで、stateがただの数字から{count: 0}のようなオブジェクトに変更されているのが確認できます。

まとめ

今回は最初の入り口としてIonic + @ngrx/storeでカウンターアプリを作ってみました。FluxやRedux, Vuexでの基本的な処理の流れを知っていれば敷居はかなり低いと思います。state, action, reducerを用意して、StoreModule.forRootにreducersを渡すだけです。

実用的なアプリになるとAPIへ問い合わせたりするような処理が加わります。その場合には@ngrx/storeに加えて@ngrx/effectsを追加することになります。@ngrx/effectsではRxJSがバリバリ使われることになるので、そちらはRxJSに馴染みがないとハードルが高いかもしれません。