React + Redux + TypeScriptで非同期処理

※更新履歴

- webpack2-rc & TS2.1 & @types対応
- webpack2 & TS2.3 & @types対応
- React16-beta & TS2.4 対応

こちらの記事の続編です。
http://qiita.com/uryyyyyyy/items/7d4b0ede3f2b973d6951

## 問題提起

今時、型チェックのない言語とか使いたくないですよね！（4回目）
reduxの非同期処理をTypeScriptで書いてみました。

## 環境

- NodeJS 8.2
- React 16.0-beta
- TypeScript 2.4
- webpack
- Fetch API

## 構成

コードはこちら。
https://github.com/uryyyyyyy/react-redux-sample/tree/async

### 設定ファイル群

reduxのミドルウェアは必要になるまで入れません！
入れないと問題になる場合に初めて検討しましょう。僕はまだ使ってないです。

今回は非同期処理としてサーバーとのやりとりを含めたいので、XMLHttpRequestの代わりとして標準化されているfetch APIを用います。[おかげ様で主要ブラウザでは実装されています](http://caniuse.com/#feat=fetch)のでそのまま使いますが、IEサポートとかするのであればpolyfillを入れてください

また、サーバーとの通信のために仮サーバーとしてexpressを用います。高機能なので実際のプロジェクトでのフロントコードの動作確認用としても僕は重宝しています。

```
npm install express --save-dev
```

コードを見ていきましょう。

### APIサーバー

```js:devServer.js
const path = require('path');
const express = require('express');
const app = express();

app.use('/dist', express.static('dist'));

app.get('/api/count', (req, res) => {
  res.contentType('application/json');
  const obj = {"amount": 100};
  setTimeout(() => res.json(obj), 500); //非同期感を出すために応答をわざと遅延させる。
  //res.status(400).json(obj); //for error testing
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(3000, (err) => {
  if (err) {
    console.log(err);
  }
  console.log("server start at port 3000")
});
```

`/api/count` へリクエストが来たら0.5秒待ってjsonを返すという単純なものです。わざと遅らせているのは、「Loading...」的なものを表示するUIにしたいので、それがわかりやすいようにです。

### src

ここからreduxのコードになります。（繰り返しますが、ミドルウェアなしで進めます。）

#### Counter.tsx

```ts:counter/Counter.tsx
import * as React from 'react'
import {CounterState} from './module'
import {ActionDispatcher} from './Container'

interface Props {
  value: CounterState
  actions: ActionDispatcher
}

export class Counter extends React.Component<Props, {}> {

  render() {
    return (
      <div>
        <p>{`score: ${this.props.value.num}`}</p>
        <button onClick={() => this.props.actions.increment(3)}>Increment 3</button>
        <button onClick={() => this.props.actions.decrement(2)}>Decrement 2</button>
        <button onClick={() => this.props.actions.asyncIncrement()}>async Increment 100</button>
        {(this.props.value.loadingCount === 0) ? null : <p>loading</p>}
      </div>
    )
  }
}
```

ここで、asyncIncrementというのが今回の対象の非同期処理とします。
もしloadingCountが1以上の時は非同期処理中ということで「Loading...」が画面に見えるようにしています。
中身であるActionDispatcherの実装を見ていきましょう。

#### ActionDispatcher

```ts:counter/module.ts
// ActionDispatcher部分のみ抜粋

export class ActionDispatcher {
  constructor(private dispatch: (action: ReduxAction) => void) {}

  myHeaders = new Headers({
    "Content-Type": "application/json",
    'Accept': 'application/json',
    'X-Requested-With': 'XMLHttpRequest'
  });

  //①
  public async asyncIncrement(): Promise<void> {
    //②
    this.dispatch(fetchRequestStart());

    try {
      //③
      const response: Response = await fetch('/api/count', {
        method: 'GET',
        headers: this.myHeaders
      });

      if (response.status === 200) { //2xx
        //④
        const json: {amount: number} = await response.json();
        this.dispatch(incrementAmount(json.amount))
      } else {
        //⑤
        throw new Error(`illegal status code: ${response.status}`);
      }
    } catch (err) { //⑥
      console.error(err);
    } finally { //⑦
      this.dispatch(fetchRequestFinish())
    }
  }
}
```

さて、ここで前回からの差分として見るべきところとしては `asyncIncrement()` の部分です。

①で、メソッドの頭にasyncが付いていて返り値がPromiseであることから、このメソッドが非同期処理をasync/awaitで取り扱うものであると宣言します。
②では非同期処理する前に、「Loading...」の画面を出すようにActionを飛ばします。
③でfetch APIを用いてサーバーへの通信処理（非同期）を行います。awaitを使うことで、Promiseを明示的に扱わなくて良くなります。

通信が上手く行って200が返ってきた場合は、④でデータを受け取って、reducerへIncrementのActionを飛ばしています。
通信は上手く行ったものの200以外（400など）が返ってきた場合は、そのステータスコードをエラーメッセージとして例外を投げています。
fetchが失敗した（サーバーの応答が無い、通信がそもそもできてないなど）の場合や、上記のエラーが投げられた時は⑥でそれを記録します。
最後に⑦で、①で行った「Loading...」表示を取り消すようにActionを発行しています。

reducerの実装はここでは省略しますが、見ればすぐに分かるかと思います。

## Buildしてみる

`npm run build` してから `npm run server` と実行してサーバを立てましょう。（Web APIを呼び出せるようにするためです。）
`async Increment 100` と書かれているボタンを押せば裏でjsonをfetchして`{amount: 100}`が返ってきて、それをIncrementするため100点が加算されるはずです。

## テスト

### ActionDispatcherのテスト

#### 準備

テスト時にfetchのリクエストが飛んでしまうのは避けたい＆サーバーのエラーもシミュレートしたい、という要望はあると思うので、ここではfetchをmockするライブラリをいれます。

```
npm install fetch-mock --save-dev
```

また、このままだと `import fetchMock from 'fetch-mock'` としたときに型定義がなくて怒られてしまうので、とりあえずanyで取り込めるようにしておきます。（TSのimplicitAnyを設定した場合、筆者はこのような回避策を取っています。）

```ts:allowAny.d.ts
declare module "fetch-mock";
```

#### ActionDispatcher.spec.ts

```ts:src/counter/__tests__/ActionDispatcher.spec.ts

...

it('asyncIncrement success', async (done) => {
    fetchMock.get('/api/count', {body: {amount: 100}, status: 200});

    const spy: any = {dispatch: null};
    spyOn(spy, 'dispatch');
    const actions = new ActionDispatcher(spy.dispatch);
    await actions.asyncIncrement();
    expect(spy.dispatch.calls.count()).toEqual(3);
    expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart());
    expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(incrementAmount(100));
    expect(spy.dispatch.calls.argsFor(2)[0]).toEqual(fetchRequestFinish());
    done();
  });

  it('asyncIncrement fail', async (done) => {
    fetchMock.get('/api/count', {body: {}, status: 400});

    const spy: any = {dispatch: null};
    spyOn(spy, 'dispatch');
    const actions = new ActionDispatcher(spy.dispatch);
    await actions.asyncIncrement();
    expect(spy.dispatch.calls.count()).toEqual(2);
    expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart());
    expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(fetchRequestFinish());
    done();
  });
```

asyncIncrement successでは、通信処理を含むAPIの正常系をテストしています。
asyncIncrement failでは異常系のテストで、通信で400を返すようにしています。
ここでも、async/awaitを使っているため、テストが同期っぽく読みやすく書けていますね。

テスト内容としては同期の場合と同じで、各処理の時にActionがdispatchされているかをテストしています。

## まとめ

特にミドルウェアを使わなくても非同期処理が出来ましたし、テストもバッチリです。

次はreact-routerを入れてみます。