TypeScript FSA での非同期処理

この記事では、 TypeScript FSA で非同期処理するときの知見を忘備録として載せます。

TypeScript FSA 自体は他の方の投稿のほうが詳しく紹介されているので、この記事ではニッチな部分をメインに取り上げて載せています。
サンプルプロジェクトはこちら

目次

TypeScript FSA 使い方

TypeScript FSA は、 “Flux Standard Action” に TypeScript で型を付けることで、 Action 作成関数と Reducer の実装を型安全に行えるようにするライブラリです。
Action の Dispatch 時の引数と Reducer での payload の型をつけられるので強力です。後者は isType という User-Defined Type Guards により実現しています。

TypeScript FSA の非同期処理の便利なツールとしては、( const actionCreator = actionCreatorFactory() と Action 作成関数ジェネレータを作成したとして) actionCreator.async<Params, Result, Error>() というメソッドがあり、 _STARTED _DONE _FAILED というポストフィックスがついた Action を一気に生成できます。
3 種類しかありませんが、簡単な処理や、単純な Action で済む場合なら十分でしょう。

Reducer 内が return ばかりになって気分は良くないかもしれないので、(私はまだ使ったことがないですが、) TypeScript FSA Reducers もいいかもしれません。

Redux Thunk と一緒に

Redux で非同期処理をする際の一番簡単なやり方は Redux Thunk という Middleware を使うことだと思います。
単純で簡単な非同期処理が多い場合、学習のしやすさという面でおすすめできます。ただし、良くない意味でも自由度が高く、複雑な非同期処理を丁寧には書きにくいという欠点はあります。

似たような処理が多くなるのでライブラリ化には都合がよかったようで、 typescript-fsa-redux-thunk というライブラリは、型と非同期処理を書くだけで、上に挙げた 3 つの Action の Dispatch までやってくれる Thunk Action を定義できます。
簡単な処理を含む Thunk Action のコードをリファクタリングしていると、自然に typescript-fsa-redux-thunk の実装に似た形に落ち着きました。 Action が自動で Dispatch されることさえ覚えておけば、後はライブラリの挙動を気にする必要はありません。

modules/thunk-duck.ts
12
13
14
15
export const delayIncrementOperation = asyncActionCreator<void, void>(
"DELAY_INCREMENT",
() => new Promise(resolve => setTimeout(() => resolve(), 250))
);

React Redux の connect での mapDispatchToProps 定義のために bindActionCreators を使うときはひと手間必要で、 countUp: thunkToAction(countUp.action) のように thunkToAction という関数と action プロパティを使って Action そのものに(ガワだけ)変換する必要があります。
そのあとは TypeScript の ReturnType などを使って、対象のコンポーネントに良しなに型をつけることができます。

ThunkView.tsx
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
countUp,
delayIncrementOperation: thunkToAction(delayIncrementOperation.action),
resetCountOperation: thunkToAction(resetCountOperation.action)
},
dispatch
);

type MappedProps = ReturnType<typeof mapStateToProps> &
ReturnType<typeof mapDispatchToProps>;

export class ThunkView extends React.Component<MappedProps> {

単体テストは後述の 2 つのライブラリに比べると簡単です。 Thunk Action のテストは、モック Store を作成しておいて Dispatch した後の Action 一覧をチェックするのが公式のおすすめ方法らしいです。 typescript-fsa-redux-thunk を利用していても、ここは変わりません。

modules/__tests__/thunk-duck.ts
30
31
32
33
34
35
36
37
const store = mockStore({} as ApplicationState);

await store.dispatch(delayIncrementOperation.action());

expect(store.getActions()).toEqual([
delayIncrementOperation.async.started(),
delayIncrementOperation.async.done({})
]);

サンプル実装: modules/thunk-duck.ts modules/__tests__/thunk-duck.ts ThunkView.tsx

Redux Saga と一緒に

Redux Saga は、非同期処理を含めた副作用のある処理を、 Saga という概念の元、 Effect を使いつつ Action をフックしたり Dispatch したりできる Middleware + ライブラリ です。
一見 yield を使うなどして非常に複雑なようなことをやっているように見えますが、ある種の Reactive + async/await プログラミングなので、案外すぐに慣れるような気がします。複雑な非同期処理を行いたいならおすすめです。(スロットリングとかキャンセルとか簡単ですからね。)

一応 typescript-fsa-redux-saga というライブラリもありますが、パラメータの渡し方と Saga の単体テストに難があるのとあんまり記述量も減らず、かつ上記 3 つの Action を自動で Dispatch するだけなので、 Redux Saga を持ち出したくなるような用途の処理に対しては力不足かなと感じました。
actionCreator.async で生成された ???_START を起点に Saga を動かす場合は、 bindAsyncAction のオプションで skipStartedAction: false としておかないと無限ループに入るので気をつけましょう。
書き方はサンプルコードのコメントに残してあります。

modules/saga-duck.ts
13
14
15
16
17
export const delayIncrementOperation = actionCreator<void>("DELAY_INCREMENT");

export const delayIncrementActions = actionCreator.async<void, void>(
"DELAY_INCREMENT_ACTIONS"
);
0
// omit 
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// export const delayIncrementSaga = bindAsyncAction(delayIncrementActions)(
// () =>
// new Promise(resolve => {
// setTimeout(() => {
// resolve();
// }, 250);
// })
// );

export function* delayIncrementSaga() {
yield put(delayIncrementActions.started());
yield delay(250);
yield put(delayIncrementActions.done({}));
}

こちらは bindActionCreators で Action も Saga Action の区別をする必要がなく、すっきり書けます。また、 ReturnType も同様にうまく効きます。

SagaView.tsx
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
countUp,
delayIncrementOperation,
resetCountOperation
},
dispatch
);

type MappedProps = ReturnType<typeof mapStateToProps> &
ReturnType<typeof mapDispatchToProps>;

export class SagaView extends React.Component<MappedProps> {

何かの非同期 Action 完了後に、 Dispatch する側で処理をしたいような場合は、 Dispatch するときの変数に Promise の resolve と reject を渡し、 Saga の終わりにそれらを使うようにしてあげるのが一つのパターンのようです。Issue #161 · redux-saga/redux-saga

modules/saga-duck.ts
88
89
90
91
92
93
94
95
const { promise, ...params } = action.payload;
yield put(resetCountActions.started(params));
try {
const resetResult = yield call(resetService, params.delay); // result is not used in this case
const state: ApplicationState = yield select();
const result = { quote: `Memento from saga. [thunk: ${state.thunk.count}, saga: ${state.saga.count}, observable: ${state.observable.count}]` };
yield put(resetCountActions.done({ params, result }));
promise && promise.resolve(result);
SagaView.tsx
59
60
61
62
63
64
65
66
67
68
onClick={() =>
new Promise<{ quote: string }>((resolve, reject) => {
this.props.resetCountOperation({
delay: 500,
promise: { resolve, reject }
});
}).then(result => {
console.log(result.quote);
})
}

Redux Thunk と比べると単体テストは若干面倒になりますが、 Redux Saga Test Plan を使えばなんとかテストできそうです。
単体テストでは testSaga 、統合テストなどでは expectSaga を使うようで、前者での next() の使い方に癖を感じました。テストコードには、テスト対象で対応する Effect をコメントとして残しておくほうが無難な気がします。また、間違えを恐れずに言えば、 next() によりテスト対象の call() をモック化できたりします。

modules/__tests__/saga-duck.ts
65
66
67
68
69
await testSaga(delayIncrementSaga).next()
.put(delayIncrementActions.started()).next()
.next() // delay call
.put(delayIncrementActions.done({})).next()
.isDone();

サンプル実装: modules/saga-duck.ts modules/__tests__/saga-duck.ts SagaView.tsx

redux-observable と一緒に

redux-observable も、非同期処理などを Reactive プログラミングを使いつつ、 Epic という関数の中で Action をフックしたり Dispatch したりできる Middleware + ライブラリです。
ReactiveExtensions や RxJS によるプログラミングに慣れていれば、すんなり使えるようになります。ウォッチ対象とする Action でフィルタリングし、処理を行い、別の Action 流すストリームを作成します。

RxJS 5 向けには typescript-fsa-redux-observable を使ってフィルタリングと TypeGuard が使えるようになります。
RxJS 6 向けには Issue #6 · m0a/typescript-fsa-redux-observable を参考に関数を作れば同じことができますが、 filterdelayIncrementOperation.match のようにマッチ関数を渡すだけで、後続の TypeGuard が正しく行われました。

modules/observable-duck.ts
75
76
77
78
79
80
81
82
83
84
85
86
87
88
export const delayIncrementEpic: Epic<Action> = action$ =>
action$.pipe(
// ofAction(delayIncrementOperation),
filter(delayIncrementOperation.match),
mergeMap(() =>
merge(
of(delayIncrementActions.started()),
of({}).pipe(
delay(250),
mapTo(delayIncrementActions.done({}))
)
)
)
);

こちらも bindActionCreators での Action の区別が必要なく、 ReturnType もうまく効きます。非同期 Action 完了の処理の作り方についても、 Redux Saga の場合と同じです。
一方、 Epic の実装が風変わりになることがあります。例えば、上のコードのように複数の Action を Dispatch する場合には、複数の Action ストリームを merge したものを実装する必要があります。

テストに関しては( RxJS を使う以上)かなり面倒で、 redux-observable でも Marble Test により行います。 from(Promise) が使えないという制約があるため、モック化にも気をつかなければなりません。
どちらかと言えば、 RxJS 由来の悩みだけになるように感じました。

modules/__tests__/observable-duck.ts
45
46
47
48
49
50
51
52
53
54
55
56
57
58
testScheduler.run(({ hot, expectObservable }) => {
const action$ = hot("-a", {
a: delayIncrementOperation()
});
const state$ = null;

const output$ = delayIncrementEpic(action$, state$, {});

// at `a` took 1ms
expectObservable(output$).toBe("-a 249ms b", {
a: delayIncrementActions.started(),
b: delayIncrementActions.done({})
});
});

サンプル実装: modules/observable-duck.ts modules/__tests__/observable-duck.ts ObservableView.tsx

最後に

TypeScript FSA で非同期処理する場合の、 Redux Thunk 、 Redux Saga 、 redux-observable の使用感を比較しました。

React Hooks の登場により Redux エコシステムがどう変化するかはまだ予測できませんが、 Redux そのものが下火にならなければこれらのライブラリも当面は残っていそうです。