概要: NgRx 8にcreateAction
やcreateReducer
、createEffect
など多くの改善機能が新たに追加されました。こうしたヘルパー機能によってボイラープレートが大幅に削減され、開発者の生産性が高まります。
NgRx 8の圧倒的勝利
Angular 8がリリースされて間もなく、NgRxチームからもバージョン8が発表されました。ライブラリに数多くの素晴らしい改良を加えたメジャーリリースです。ぜひTim Deschryver氏による公式リリース発表を読んでいただきたいのですが、ここにも概要を書いておきます。
- John Papa氏とWard Bell氏による
angular-ngrx-data
が@ngrx/data
として正式にNgRxの一部になった。 - ランタイムチェックを使ってステートとアクションに異変がなくシリアライズ可能であることをチェックできるようになった(これらは
ngrx-store-freeze
パッケージと置き換えられる)。 - モックセレクタのおかげで完全に分離されたユニットテストを行えるようになった。
- 新たな最小
routerState
オプションなど、@ngrx/router
にいくつか改良が加わった。 - John Papa氏のAngularスニペットのVS CodeエクステンションにNgRxのスニペットが追加された。
Alex Okrushko氏とWes Grimes氏がNgRxチームに加入した(納得!)。
ではそろそろ本命の話題に入りましょう。この記事のテーマは次のとおりです:
createAction
、createReducer
、createEffect
のヘルパーメソッドのおかげでボイラープレートが劇的に削減され、開発時間を短縮
以下にこれがどういうことかを詳しく説明し、これらの機能がどのように役立つのか探っていきますが、その前に、皆さんがこの新機能をフルに活用できるよう、バージョン8にアップデートする方法を復習しておきたいと思います。
"NgRx 8は、開発者エルゴノミクスの改善、ボイラープレートの削減、ランタイムチェック、そしてエラー処理の簡易化を特徴としています!"
Tweet This
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の新アクションクリエータは、新機能を追加する際の開発時間を劇的に削減します!"
Tweet This
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アカウントにご登録ください。チュートリアルを使ってすぐに作業を始められるようになります。
ところで、バージョン8の新ヘルパー機能を使用してみた感想をぜひお聞きかせください。いつでもコミュニティフォーラムにてご連絡ください。
終わりに
皆さんはどうか分かりませんが、私はNgRx 8にとても満足しています。このチームは、素晴らしいソフトウェアを作り、phenomenal docsの書き込みも行っているボランティアの集まりです。このバージョンには改良点がたくさん盛り込まれています。将来は明るいですね。皆さんも実際に試してみて、ぜひ感想をお聞かせください!