概要: NgRx 8にcreateActioncreateReducercreateEffectなど多くの改善機能が新たに追加されました。こうしたヘルパー機能によってボイラープレートが大幅に削減され、開発者の生産性が高まります。

NgRx 8の圧倒的勝利

Angular 8がリリースされて間もなく、NgRxチームからもバージョン8が発表されました。ライブラリに数多くの素晴らしい改良を加えたメジャーリリースです。ぜひTim Deschryver氏による公式リリース発表を読んでいただきたいのですが、ここにも概要を書いておきます。

ではそろそろ本命の話題に入りましょう。この記事のテーマは次のとおりです:

  • createActioncreateReducercreateEffectのヘルパーメソッドのおかげでボイラープレートが劇的に削減され、開発時間を短縮

以下にこれがどういうことかを詳しく説明し、これらの機能がどのように役立つのか探っていきますが、その前に、皆さんがこの新機能をフルに活用できるよう、バージョン8にアップデートする方法を復習しておきたいと思います。

"NgRx 8は、開発者エルゴノミクスの改善、ボイラープレートの削減、ランタイムチェック、そしてエラー処理の簡易化を特徴としています!"

NgRx 8へのアップデート方法

NgRx 8にアップデートするには、プロジェクトがAngular 8にアップデートされているかを確認する必要があります。次のコマンドを実行すると、Angular CLIをグローバルにアップデートできます。

npm install -g @angular/cli

Angular 8にアップデートしなくてはならないプロジェクト内で、次のコマンドを実行してください。

ng update @angular/cli @angular/core

その後、次のコマンドを実行すると、NgRx 8にアップデートできます。

ng update @ngrx/store

実はこの記事の執筆中、TypeScriptのバージョンに少々不具合が生じてしまいました。必要なバージョン間でコンフリクトが生じたようです。そのため、バージョン3.4.3で手動でロックしなければなりませんでした。

ngrx-dataを使用している場合は次のコマンドで@ngrx/dataに簡単に移行できます。

ng add @ngrx/data --migrate

このプロセスについては、Wes Grimes氏がAngular 8とNgRx 8のアップグレード方法という素晴らしい記事で詳しく説明されています。NgRx 8への移行に関する具体的な詳細については、公式の移行ガイドを確認してください。最後に、Angularには新バージョンへの移行を支援してくれる公式アップデートツールがありますのでお忘れなく。

では、いよいよお楽しみの本題に入ります。

createExcitement:NgRx 8の新しいヘルパー機能

NgRx(およびReduxのパターン全般)についてよく聞かれる不平の1つとして、ボイラープレートがあまりにも多いことが挙げられます。しかしライブラリとパターンを調べてみると、大規模で複雑なアプリケーションで状態を管理している場合(そもそもNgRxが設計された理由はここにあります!)、初期設定はまさに価値ある投資だということが分かってきます。

とは言ってもNgRxチームは開発者コミュニティの声に耳を傾け、"開発者エルゴノミクス"(これは単に「コードを書きやすくする」という意味ですが)の大きな改良点をいくつかバージョン8に盛り込みました。こうした改良のほとんどは、アクション、リデューサ、エフェクトの設定方法のリファクタリングによるものです。これからは簡単に理解できてコードも少なくて済むヘルパーメソッドを利用できます。では順を追って見ていきましょう。

createAction

私の友人Mike Ryan氏は「アクションはNgRxという塊のグルテンだ」と言いますが、実際にアクションはNgRxアプリケーションの中核です。アプリケーションが必要としているアクションを決定するには細心かつ慎重な注意が求められます。残念なことにアクションの設定には大量のコードが必要です。例えば、私が以前書いたNgRx認証チュートリアルで構築したアプリケーションの抜粋を見てください。

// src/auth/actions/auth.actions.ts(抜粋)
export enum AuthActionTypes {
  Login = '[Login Page] Login',
  LoginComplete = '[Login Page] Login Complete',
  LoginFailure = '[Auth API] Login Failure'
}

export class Login implements Action {
  readonly type = AuthActionTypes.Login;
}

export class LoginComplete implements Action {
  readonly type = AuthActionTypes.LoginComplete;
}

export class LoginFailure implements Action {
  readonly type = AuthActionTypes.LoginFailure;

  constructor(public payload: any) {}
}

export type AuthActions =
  | Login
  | LoginComplete
  | LoginFailure;

この抜粋に含まれるのはアプリケーションの8つのアクションのうち3つだけです。しかもこれは非常に小さいアプリケーションなのです!Alex Okrushko氏が新しいアクションクリエーターと題した素晴しい記事で指摘しているように、enumとstringのせいもありアクションが使われている場所が非常に分かりにくくなっています。おまけにunion型ときた日にはたまったものではありません!ですから変更が必要だった理由はお分かりいただけましたね。

そこで登場したのがcreateActionメソッドです。createActionでは、アクション名を、オプションで特定の引数タイプを分割されたオブジェクトとする関数propsと一緒に渡します。

おっと、これでは難解な用語が多すぎますね。例を用いて説明しましょう。

その作成時にBook[]型の本(books)の配列を受け取る必要のある、booksLoadedというアクションがあるとします。ここではcreateAction用のprops関数はprops<{ books: Book[] }>()になります(分割により{ books }{ books: books }と同等になります)。

export const booksLoaded = createAction(
  '[Books API] Books Loaded', 
  props<{ books: Book[] }>()
  );

この方法では、この型の新しいアクションを作るときにコンストラクタnew booksLoaded({ booksFromServer })に直接bookの配列を渡すことができます。

すると、上記に示した認証アクションは次のようにリファクタリングされます。

export const login = createAction('[Login Page] Login');

export const loginComplete = createAction('[Login Page] Login Complete');

export const loginFailure = createAction(
  '[Auth API] Login Failure', 
  props<{ error: any }>()
  );

どうですか、すごいでしょう?

ここで気を付けたい点が2つあります。まず、アクションの定数名がPascalケース(LoginFailure)からCamelケース(loginFailure)に変わっていますね。これは、アクションがクラスから関数に変わったためで、JavaScriptでは標準の表記法です。これに惑わされず、リファクタリングの際には注意してください。

2つ目は、名前を分かりやすくするためpayloadからerrorに変更したことです(Errorのような新しいタイプにすることも可能ですが、今回の例ではあまり問題になりません)。これは、アクションクリエータ関数が再設計された素晴らしい新機能ですが、使用時には注意してください。payloadプロパティに依存するアプリケーションの他の部分を壊さないように、コードをよく検索してください(Gregor Woisode氏からもリマインダが出ています)。アクションクリエータでpayloadの名前を変更する場合、必ずすべての箇所で変更してください。

最後に、新アクションクリエータには素晴しい機能がもう1つあります。それはprop引数のデフォルト値を指定できるようになったことです。例えば、上記のloginFailureアクションで、次のデフォルトのエラーメッセージを提供できます。

export const loginFailure = createAction(
  '[Auth API] Login Failure',
  (errorMessage = 'Error logging in') => ({ payload: { errorMessage }})
  );

これは、何らかの理由でサーバーがエラーメッセージを提供できない場合のバックアップとして有効です。

このような新アクションクリエータをアプリケーションに追加するために、アップデートされたアクションschematicを次のように使用できます。

ng generate @ngrx/schematics:action myNewAction --creators

このコマンドは、createAction関数を使用して、loadMyNewActionという名前の定数を含むmy-new-action.actions.tsと呼ばれる新しいファイルを生成します。

"NgRx 8の新アクションクリエータは、新機能を追加する際の開発時間を劇的に削減します!"

createReducer

新しいアクション作成機能は素晴らしいですが、ほかにも改良点があります。リデューサもまた、createReducerメソッドのおかげで簡単に書けるようになりました。

ここではバーガーレストランのアプリケーションなどに見られるリデューサの典型例をお見せしましょう。

export function reducer(
  state = initialState,
  action: BurgerApiActionsUnion
  ): State {
  switch (action.type) {
    case BurgerApiActionTypes.LoadBurgerCollection: {
      return { 
        ...state
        loading: true
      };
    }

    case BurgerApiActionTypes.LoadBurgersSuccess: {
      return { 
        loaded: true,
        loading: false,
        ids: action.payload.map(burger => burger.id)
      };
    }

    default: {
      return state;
    }
  }
}

これは簡単な例ですが、スケールアップするにつれてcase文の数が増大し、コードが分かりにくくなります。すぐに収拾がつかない状態になってしまいます。まるでハンバーガーを食べるときのように。

それがNgRx 8では、次のように代わりにcreateReducerを使えます。

const burgerReducer = createReducer(
  initialState,
  on(BurgerApiActionTypes.loadBurgerCollection, state => ({
    ...state,
    loading: true
  })),
  on(BurgerApiActionTypes.loadBurgersSuccess, (state, { burgers }) => ({
    loaded: true,
    loading: false,
    ids: burgers.map(burger => burger.id)
  }))
  );

この方がずっと分かりやすいですね。各caseが、アクションと状態を変更する関数を受け取るon関数を使用します。

on関数は複数のアクションを受け入れることができるので、リデューサ関数を簡単に拡張できます。例えば、レコードの追加に成功した場合とレコードの削除に失敗した場合、その結果得られるステートは同じです。その良い例が、データのコレクション(サーバー上のものと一致する必要あり)と、検索を高速化するためIDの配列(メモリに保存)の両方がアプリケーションステートに含まれている場合です。IDの配列がサーバー上のものと常に確実に一致するようにするには、レコードの追加や削除に失敗する可能性を考慮する必要があります。

バーガーアプリケーションの例で見てみましょう。

const burgerReducer = createReducer(
  // ...その他のcase、
  on(
    CollectionApiActions.addBurgerSuccess,
    CollectionApiActions.removeBurgerFailure,
    (state, { burger }) => {
      if (state.ids.indexOf(burger.id) > -1) {
        return state;
      }
      return {
        ...state,
        ids: [...state.ids, burger.id],
      };
    }
  ),
  on(
    CollectionApiActions.removeBurgerSuccess,
    CollectionApiActions.addBurgerFailure,
    (state, { burger }) => ({
      ...state,
      ids: state.ids.filter(id => id !== burger.id),
    })
  )
  );

複数のアクションに同じリデューサケースを使用すると、アプリケーションで行われている処理をより簡単に把握できるだけでなく、ids配列が常にサーバーとの同期を保てます。

createReducerを使用する上で注意すべきことがもう1つあります。本稿の執筆時点で、AngularのAhead-of-time(AOT)コンパイラ(プロダクションビルドの作成に使用するコンパイラ)は、関数式に対応していません。そのため、エクスポートされた関数内でcreateReducer関数を次のようにラップする必要があります。

export function reducer(state: State | undefined, action: Action) {
  return burgerReducer(state, action);
}

最後に、createActionの場合と同様に、新しいリデューサスタイルのschematicを利用できます。

ng generate @ngrx/schematics:reducer myNewReducer --creators

このschematicでmy-new-reducer.reducer.tsと呼ばれる新しいファイルが生成されます。これにはcreateReducer定数とAOT用にエクスポートされたreducer関数の両方が含まれます。

createEffect

最後に、エフェクトの作成を改善するために追加されたcreateEffectについてです。これまでエフェクトは@Effect()デコレータを使用していました。これはこれで良かったのですが、NgRxで使われる他のより関数的なアプローチに比べると、ちょっと風変わりな存在でした。

バーガーアプリケーションの例に戻って、旧式のエフェクト例を見てみましょう。

@Effect()
loadBurgerCollection$ = this.actions$.pipe(
  ofType(BurgerApiActionTypes.LoadBurgerCollection),
  switchMap(() =>
    this.burgerService.getAllBurgers().pipe(
      map((burgers: Burger[]) =>
        BurgerApiActions.LoadBurgersSuccess({ burgers })
      ),
      catchError(error =>
        of(BurgerApiActions.LoadBurgersFailure({ error }))
      )
    )
  )
  );

新しいcreateEffectヘルパーではこれを少し簡略化して標準化し、アクション、リデューサ、セレクタにより近い感じになっています。

loadBurgerCollection$ = createEffect(() =>
  this.actions$.pipe(
    ofType(BurgerApiActionTypes.loadBurgerCollection),
    switchMap(() =>
      this.burgerService.getCollection().pipe(
        map((burgers: Burger[]) =>
          BurgerApiActions.loadBooksSuccess({ burgers })
        ),
        catchError(error =>
          of(BurgerApiActions.loadBooksFailure({ error }))
        )
      )
    )
  )
  );

アクションはクラスから関数に変更されたため、PascalケースからCamelケースに変わったことを忘れないでください。

また、新しいアクションをディスパッチしないエフェクトに小さな変更が加えられています。次はバーガーが正しく注文された後にトリガされるエフェクトですが、リダイレクトされてホームページに戻り、新しいアクションをディスパッチしません。

@Effect({ dispatch: false })
orderBurgerSuccess$ = this.actions$.pipe(
  ofType(BurgerApiActions.OrderBurgerSuccess),
  tap(() => {
    this.router.navigate(['/']);
  })
  );

新しいcreateEffect関数を使用すると、このコードを次のようにリファクタリングできます。

orderBurgerSuccess$ = createEffect(() => 
  this.actions$.pipe(
    ofType(BurgerApiActions.LoginSuccess),
    tap(() => this.router.navigate(['/']))
  ),
  { dispatch: false }
  );

createEffect関数の2番目の引数は、エフェクトの動作を制御するメタデータで渡すためのものです。

NgRx 8のエフェクトについて最後に一言。これまでは、開発者がエフェクト内のエラー処理を忘れているか、適切に処理していないかのどちらかだと考えられていました。この問題を緩和するために、これからはエフェクトが自動的にエラー時に(完了するのでなく)サブスクライブし直し、ディスパッチされるアクションを待機し続けます。これはとても便利ですが、もしも動作が変になり始めたら無効にすることもできます。その場合は{ resubscribeOnError: false }createEffect関数の2番目の引数としてパスするだけです。

他の2つの例と同様に、アプリケーションにエフェクトの新しいスタイルを追加するschematicがあるのでご覧ください。

ng generate @ngrx/schematics:effect myNewEffect --creators

このコマンドは、createEffectをインポートしてInjectableデコレータでマークされたクラスを作成する、my-new-effect.effects.tsという新しいファイルを生成します。

ついでに:"NgRxは不要"なときでも認証が必要になる場合があります

今日のアプリケーションでは、ユーザープロファイル情報を保持して認証と認可を処理するために、何らかのアイデンティティ管理が必要となることがほとんどです。NgRxアプリケーションでアイデンティティ管理戦略を実装する場合、バージョン8で構文の一部が変更されたものの、その中核をなす原理に変わりはありません。ログインとログアウトにはアクションを、認証サービスの呼び出しにはエフェクトを使用する一方で、ログインのステータスやプロファイル属性などの状態の変更はリデューサによって処理します。

この詳細については、NgRx認証チュートリアルに書いておきました。ぜひにアクセスして無料のAuth0アカウントにご登録ください。チュートリアルを使ってすぐに作業を始められるようになります。

Auth0の軽量かつユニバーサルなログインモーダル画面

ところで、バージョン8の新ヘルパー機能を使用してみた感想をぜひお聞きかせください。いつでもコミュニティフォーラムにてご連絡ください。

終わりに

皆さんはどうか分かりませんが、私はNgRx 8にとても満足しています。このチームは、素晴らしいソフトウェアを作り、phenomenal docsの書き込みも行っているボランティアの集まりです。このバージョンには改良点がたくさん盛り込まれています。将来は明るいですね。皆さんも実際に試してみて、ぜひ感想をお聞かせください!