かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

React + TypeScript JSX + Reduxで非同期処理を呼ぼう

公式ドキュメントのここらへん。

Async Actions | Redux

そもそも

普通のSPAだと、AJAX呼び出しとかで非同期な処理が盛りだくさん。Reduxでどうやるの?という風になるのですが、これが素のReduxのライブラリだけだと難しいということで、redux-thunkというミドルウェアを入れないといけないということです。

もろもろインストール

以下のものをインストールしておきます。

  • object-assign
  • react
  • react-redux
  • redux
  • redux-thunk

作るもの

足し算アプリを作ります。左辺値と右辺値を入れてボタンを押したら3秒後に足した結果を表示するというものです。setTimeoutでやりますが、まぁ実際はsetTimeoutじゃなくてajax呼び出しのコールバックに置き換わるという感じです。

数字を入力してボタンを押した直後(まだ答えは反映されてない)

f:id:okazuki:20160109192156p:plain

一定時間が経過すると、答えが表示される(Ajaxから計算結果が返ってきた風)

f:id:okazuki:20160109192219p:plain

データの入れ物

アプリのデータとして以下のようなクラスを準備します。

// models.ts
export class Calc {
    constructor(
        public x: number,
        public y: number,
        public answer: number) {
    }
}

アクション

アクションを作ります。x, yを受け取るaddと答えをセットするsetAnswerという2つのアクションを定義します。

// actions.ts
import * as Redux from 'redux';

export enum ActionTypes {
    Add,
    SetAnswer
}

export interface Action<T> {
    type: ActionTypes;
    payload: T;
}

export class AddPayload {
    constructor(
        public x: number,
        public y: number) {
    }
}

export class SetAnswerPayload {
    constructor(public answer: number) {
    }
}

export function add(x: number, y: number): Action<AddPayload> {
    return {
        type: ActionTypes.Add,
        payload: new AddPayload(x, y)
    };
}

export function setAnswer(answer: number): Action<SetAnswerPayload> {
    return {
        type: ActionTypes.SetAnswer,
        payload: new SetAnswerPayload(answer)
    };
}

次がポイントです。非同期処理のアクションを定義します。このアクションはRedux.Dispatchを受けとる関数を返します。この関数の中でRedux.Dispatchを使って非同期処理の前や非同期処理の完了後に別のアクションを呼び出します。

// actions.tsの続き
export function fetchAdd(x: number, y: number) {
    return (dispatch: Redux.Dispatch) => {
        dispatch(add(x, y));

        setTimeout(() => {
            dispatch(setAnswer(x + y));
        }, 3000);
    };
}

今回の場合は、最初にx,yを受け取るアクションを実行して、3秒後に計算結果をsetAnswerアクションに渡しています。

Reducer

Reducerは通常通りですね。

// reducers.ts
import * as Redux from 'redux';
import * as Models from './models';
import * as Actions from './actions';
import assign = require('object-assign');

export function add(state: Models.Calc, action: Actions.Action<Actions.AddPayload>): Models.Calc {
    switch (action.type) {
        case Actions.ActionTypes.Add:
            return assign({}, state, {
                x: action.payload.x,
                y: action.payload.y
            } as Models.Calc);
        default:
            return state;
    }
}

export function setAnswer(state: Models.Calc, action: Actions.Action<Actions.SetAnswerPayload>): Models.Calc {
    switch (action.type) {
        case Actions.ActionTypes.SetAnswer:
            return assign({}, state, {
                answer: action.payload.answer
            } as Models.Calc);
        default:
            return state;
    }
}

export function calc(state: Models.Calc = new Models.Calc(0, 0, 0), action: Actions.Action<any>): Models.Calc {
    switch (action.type) {
        case Actions.ActionTypes.Add:
            return add(state, action);
        case Actions.ActionTypes.SetAnswer:
            return setAnswer(state, action);
        default:
            return state;
    }
}

export interface CalcAppState {
    calc: Models.Calc;
}

export const calcApp = Redux.combineReducers({
    calc
});

ポイントは、CalcAppStateで、アプリのステートを明示的に型指定してるところですかね。combineReducersの呼び出しの近くに書いておくと実態との同期漏れがなくなりそうです。

コンポーネント

次にコンポーネントを作ります。とりあえずシンプルな例なのでコンポーネントは1つだけです。

// components.tsx
import * as React from 'react';
import * as Redux from 'redux';
import * as Models from './models';
import * as Actions from './actions';

export interface AppProps extends React.Props<{}> {
    calc?: Models.Calc;
    dispatch?: Redux.Dispatch;
}

export class App extends React.Component<AppProps, {}> {
    private handleSubmit(e: React.SyntheticEvent) {
        e.preventDefault();
        var x = parseInt((this.refs['x'] as HTMLInputElement).value);
        var y = parseInt((this.refs['y'] as HTMLInputElement).value);
        if (x && y) {
            this.props.dispatch(Actions.fetchAdd(x, y));
        }
    }

    render() {
        var { calc, dispatch } = this.props;
        return (
            <div>
                <form onSubmit={this.handleSubmit.bind(this)}>
                    <input type='text' ref='x' />
                    <span>+</span>
                    <input type='text' ref='y' />
                    <hr />
                    <span>{calc.x} + {calc.y} = {calc.answer}</span>
                    <br />
                    <input type='submit' value='計算' />
                </form>
            </div>
        );
    }
}

formのonSubmitのハンドラの中で、fetchAddを呼び出しています。ここで非同期処理開始ですね。

ミドルウェアの適用

さて、アクションでRedux.Dispatchを受けとる関数を返す形のアクションを作りましたがreact-thunkをちゃんと適用しないと実行時エラーになります。最後の仕上げapp.tsxでRedux.applyMiddlewareでredux-thunkを適用するcreateStore関数を作ります。

// app.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as Reducers from './reducers';
import * as Redux from 'redux';
import {Provider, connect} from 'react-redux'
import * as Components from './components';
import thunk = require('redux-thunk');

const createStoreWithMiddleware = Redux.applyMiddleware(thunk as any)(Redux.createStore);

function select(state: Reducers.CalcAppState): Components.AppProps {
    return {
        calc: state.calc
    };
}

const App = connect(select)(Components.App);
const store = createStoreWithMiddleware(Reducers.calcApp);

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('content'));

redux-thunkの型定義がイマイチっぽいので、anyへのキャストが入ってますが、Redux.applyMiddlewareに適用したいミドルウェアを渡して出来上がったものにRedux.createStoreを渡します。出来上がったものに対してReducerを渡してstoreを作ります。普通のcreateStoreを使わないところに注意ですね。

まとめ

ミドルウェア入れなくても標準でサポートしておいてほしかったと思わなくもない非同期呼び出しでした。