LoginSignup
41

More than 5 years have passed since last update.

TypeScriptでRedux Thunkを使う

Last updated at Posted at 2018-12-04

はじめに

Reactで非同期処理を扱う場合、いくつかの方法がありますが、Redux Thunkで処理するのがよくあるお手軽パターンかと思います。
実は、途中まで書いて、しばらく放置してあったのですが、React hooksが本格化する前の供養です。

ちょっとしたRedux Thunkの説明

Redux ThunkのReadMeには、このように書かれています。

Redux Thunk middleware allows you to write action creators that return a function instead of an action. The thunk can be used to delay the dispatch of an action, or to dispatch only if a certain condition is met. The inner function receives the store methods dispatch and getState as parameters.

ざっくりと訳しますと、

Redux Thunkミドルウェアでは、Actionの代わりに関数を返すAction Creatorを使うことができます。そのため、Actionのdispatchを遅延させたり、特定の条件下のみdispatchするようにしたりできます。戻り値となる関数では、dispatchとgetStateが使えます。

とこんな感じですね。
つまり、ActionがDispatchされてReducerにたどり着く前に処理を挟むことが出来るようにするものです。

Redux thunkの前にreduxreact-reduxの簡単なおさらい

さて、本題に入る前にreduxreact-reduxを使った簡単なカウンターを作ってみましょう。
まずは、StateとActionを作ります。Actionはインクリメントとデクリメントを用意しておきます。せっかくなので増減幅は外側から渡せるようにしておきます。Stateはカウンタの値を保持しておけばOKです。

/src/modules/Types/Counter.ts
import { Action } from 'redux';

export type IncrementPayload = {
    value: number;
};

export interface IncrementAction extends Action {
    type: 'INCREMENT';
    payload: IncrementPayload;
}

export type DecrementPayload = {
    value: number;
};

export interface DecrementAction extends Action {
    type: 'DECREMENT';
    payload: DecrementPayload;
}

export type CounterActions = IncrementAction & DecrementAction;

export type CounterState = {
    counter: number;
};

ActionやStateをRootActionやRootStateとして、まとめておきます。

/src/modules/Types/index.ts
import { CounterState, CounterActions } from './Counter';
export {
    IncrementPayload,
    DecrementPayload,
    CounterState,
    CounterActions,
} from './Counter';

export type RootState = {
    counter: CounterState;
};

export type RootActions = CounterActions;

次は、ActionCreatorです。

src/modules/Actions/Counter.ts
import { ActionCreator, Dispatch, Action } from 'redux';
import {
    IncrementPayload,
    DecrementPayload,
    RootActions,
    RootState,
} from '../Types';

export const increment: ActionCreator<RootActions> = (
    payload: IncrementPayload
): RootActions =>
    ({
        payload,
        type: 'INCREMENT',
    } as RootActions);

export const decrement: ActionCreator<RootActions> = (
    payload: DecrementPayload
): RootActions =>
    ({
        payload,
        type: 'DECREMENT',
    } as RootActions);

最後にReducerです。

/src/modules/Reducers/Counter.ts
import { CounterState, CounterActions } from '../Types';

export const counterReducer = (
    state: CounterState = { counter: 0 },
    action: CounterActions
) => {
    switch (action.type) {
        case 'INCREMENT':
            return {
                counter: state.counter + action.payload.value,
            };
        case 'DECREMENT':
            return {
                counter: state.counter - action.payload.value,
            };
        default:
            return state;
    }
};

あとでReducerを増やしますので、今は一つしかありませんがcombineReducersもここであらかじめしておきます。

/src/modules/Reducers/index.ts
import { combineReducers } from 'redux';
import { RootState, RootActions } from '../Types';
import { counterReducer } from './Counter';

export const rootReducer = combineReducers<RootState, RootActions>({
    counter: counterReducer,
});

これで準備完了なので、modulesからexportしておきます。

/src/modules/index.ts
export { RootState, RootActions } from './Types';
export { actionCreator } from './Actions';
export { rootReducer } from './Reducers';

では最後に、これらをいわゆるContainer componentにconnectしましょう。
まずはカウンター部分です。

/src/component/Counter.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { RootState } from '../modules';

type OutterProps = {
    label: string;
};

type StateProps = {
    value: number;
};

type Props = OutterProps & StateProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            <span>{`${props.label}: ${props.value.toString()}`}</span>
        </div>
    );
};

const mapStateToProps = (
    state: RootState,
    _ownProps: OutterProps
): StateProps => ({
    value: state.counter.counter,
});

export default connect<StateProps, {}, OutterProps, RootState>(
    mapStateToProps
)(component);

コントローラも作りましょう。

/src/component/Controller.tsx
import * as React from 'react';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { RootState, actionCreator, RootActions } from '../modules';

type OutterProps = {
    label: {
        inc: string;
        dec: string;
    };
};

type DispatchProps = {
    increment: () => void;
    decrement: () => void;
};

type Props = OutterProps & DispatchProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            <button
                onClick={_ => {
                    props.increment();
                }}
            >
                {props.label.inc}
            </button>
            <button
                onClick={_ => {
                    props.decrement();
                }}
            >
                {props.label.dec}
            </button>
        </div>
    );
};

const mapDispatchToProps = (
    dispatch: Dispatch<RootActions>,
    _ownProps: OutterProps
): DispatchProps => ({
    increment: () => {
        dispatch(actionCreator.counter.increment({ value: 1 }));
    },
    decrement: () => {
        dispatch(actionCreator.counter.decrement({ value: 1 }));
    },
});

export default connect<{}, DispatchProps, OutterProps, RootState>(
    null,
    mapDispatchToProps
)(component);

あとはこれらを並べるだけですね。ということで以下略。

Redux thunkを使ってAyncIncrement/AsyncDecrement

さてここからはRedux Thunkを使ってみましょう。1秒待ってから10増減させるようなボタンを作ってみます。
待ってる間は、増減ボタンを押せないようにして、処理が終わったあと、またボタンを押せるようにしましょう。

前準備

まずはボタンのStateとActionを追加してみます。

/src/modules/Types/Controller.ts
import { Action } from 'redux';

export type SetEnablePayload = {
    enable: boolean;
};

export interface SetEnableAction extends Action {
    type: 'SET_ENABLE';
    payload: SetEnablePayload;
}

export type ControllerActions = SetEnableAction;

export type ControllerState = {
    enable: boolean;
};

RootStateとRootActionsも更新しましょう。

/src/modules/Types/index.ts
import { CounterState, CounterActions } from './Counter';
+ import { ControllerState, ControllerActions } from './Controller';

export {
    IncrementPayload,
    DecrementPayload,
    CounterState,
    CounterActions,
} from './Counter';

+ export {
+     SetEnablePayload,
+     ControllerActions,
+     ControllerState,
+ } from './Controller';

export type RootState = {
    counter: CounterState;
+     controller: ControllerState;
};

- export type RootActions = CounterActions;
+ export type RootActions = CounterActions & ControllerActions;

Reducerも更新します。

/src/modules/Reducers/index.ts
import { combineReducers } from 'redux';
import { RootState, RootActions } from '../Types';
import { counterReducer } from './Counter';
+ import { controllerReducer } from './Controller';

export const rootReducer = combineReducers<RootState, RootActions>({
    counter: counterReducer,
+    controller: controllerReducer,
});

コンポーネントもStateからきちんと可・不可を取り出すように変更します。

/src/component/Controller.tsx
import * as React from 'react';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { RootState, actionCreator, RootActions } from '../modules';

type OutterProps = {
    label: {
        inc: string;
        dec: string;
    };
};

type StateProps = {
    disabled: boolean;
};

type DispatchProps = {
    increment: () => void;
    decrement: () => void;
};

type Props = OutterProps & StateProps & DispatchProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            <button
                onClick={_ => {
                    props.increment();
                }}
                disabled={props.disabled}
            >
                {props.label.inc}
            </button>
            <button
                onClick={_ => {
                    props.decrement();
                }}
                disabled={props.disabled}
            >
                {props.label.dec}
            </button>
        </div>
    );
};

const mapStateToProps = (
    state: RootState,
    _ownProps: OutterProps
): StateProps => ({
    disabled: !state.controller.enable,
});

const mapDispatchToProps = (
    dispatch: Dispatch<RootActions>,
    _ownProps: OutterProps
): DispatchProps => ({
    increment: () => {
        dispatch(actionCreator.counter.increment({ value: 1 }));
    },
    decrement: () => {
        dispatch(actionCreator.counter.decrement({ value: 1 }));
    },
});

export default connect<StateProps, DispatchProps, OutterProps, RootState>(
    mapStateToProps,
    mapDispatchToProps
)(component);

Thunk Actionをつくる

ここからThunkを使ってみます!
基本的には、「dispatchgetStateを受け取るような関数」を返す関数を作る感じです。dispatchを受け取っていますので、関数内では他のactionをディスパッチできます。
戻り値となる関数の戻り値型はThunkAction<R, S, E, A>で、

  • 第一引数がDispatchされたActionの戻り値型
  • 第二引数がStateの型
  • 第三引数はdispatchgetStateの他にもうひとつ取れる引数の型
  • 第四引数がActionの型です

外側から使うときは普通のAction Creatorと似たような感じで呼び出しますので、引数も当然受け取れます。

/src/modules/Actions/Counter.ts
import { ActionCreator, Dispatch, Action } from 'redux';
import {
    IncrementPayload,
    RootActions,
    DecrementPayload,
    SetEnablePayload,
    RootState,
} from '../Types';
import { ThunkAction } from 'redux-thunk'; // ここ追加

export const increment: ActionCreator<RootActions> = (
    payload: IncrementPayload
): RootActions =>
    ({
        payload,
        type: 'INCREMENT',
    } as RootActions);

export const decrement: ActionCreator<RootActions> = (
    payload: DecrementPayload
): RootActions =>
    ({
        payload,
        type: 'DECREMENT',
    } as RootActions);

export const setEnable: ActionCreator<RootActions> = (
    payload: SetEnablePayload
): RootActions =>
    ({
        payload,
        type: 'SET_ENABLE',
    } as RootActions);

// ここから追加
export const asyncIncrement = (
    payload: IncrementPayload
): ThunkAction<void, RootState, undefined, RootActions> => (
    dispatch: Dispatch<Action>                         // 今回はgetStateしないので渡さなくてOK
) => {
    dispatch(setEnable({ enable: false }));            // ボタンを利用不可に
    setTimeout(() => {
        dispatch(increment({ value: payload.value })); // インクリメントをディスパッチ
        dispatch(setEnable({ enable: true }));         // ボタンを利用可に
    }, 1000);
};

export const asyncDecrement = (                        // Decrementも同様に
    payload: DecrementPayload
): ThunkAction<void, RootState, undefined, RootActions> => (
    dispatch: Dispatch<Action>
) => {
    dispatch(setEnable({ enable: false }));
    setTimeout(() => {
        dispatch(decrement({ value: payload.value }));
        dispatch(setEnable({ enable: true }));
    }, 1000);
};
/src/modules/Actions/index.ts
import {
    increment,
    decrement,
+    asyncIncrement,
+    asyncDecrement,
} from './Counter';

export const actionCreator = {
    counter: {
        increment,
        decrement,
+        asyncIncrement,
+        asyncDecrement,
    }
};

コンポーネントにつないでみる

では、コンポーネントにつないでみましょう。
気をつけるのは、mapDispatchToPropsにはDispatchではなくThunkDispatch<S, E, A>を渡すという部分だけで、それ以外は普通の場合と同じです。

  • 第一引数がStateの型
  • 第二引数はdispatchgetStateの他にもうひとつ取れる引数の型
  • 第三引数がActionの型です
/src/component/AsyncController.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { RootState, actionCreator, RootActions } from '../modules';
import { ThunkDispatch } from 'redux-thunk';

type OutterProps = {
    label: {
        inc: string;
        dec: string;
    };
};

type StateProps = {
    disabled: boolean;
};

type DispatchProps = {
    increment: () => void;
    decrement: () => void;
};

type Props = OutterProps & StateProps & DispatchProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            <button
                onClick={_ => {
                    props.increment();
                }}
                disabled={props.disabled}
            >
                {props.label.inc}
            </button>
            <button
                onClick={_ => {
                    props.decrement();
                }}
                disabled={props.disabled}
            >
                {props.label.dec}
            </button>
        </div>
    );
};

const mapStateToProps = (
    state: RootState,
    _ownProps: OutterProps
): StateProps => ({
    disabled: !state.controller.enable,
});

const mapDispatchToProps = (
    dispatch: ThunkDispatch<RootState, undefined, RootActions>,
    _ownProps: OutterProps
): DispatchProps => ({
    increment: () => {
        dispatch(actionCreator.counter.asyncIncrement({ value: 10 }));
    },
    decrement: () => {
        dispatch(actionCreator.counter.asyncDecrement({ value: 10 }));
    },
});

export default connect<StateProps, DispatchProps, OutterProps, RootState>(
    mapStateToProps,
    mapDispatchToProps
)(component);

これでパーツはできましたので、これらを並べるだけですね。ここでも以下略。

Redux Thunkをつかってデータをfetchする

あとは同じだよみたいな感じで、これを書いてくれてないところが多くて、「うん、まぁそうなんだけどさ、うん。」という感じがしますので、これも書いておきましょう。Githubのapiにアクセスして情報を表示してみます。
基本的には、スタートするActionと、データを上手く受け取れたときのActionと、fetchに失敗したときのActionを用意しておき、それぞれを呼ぶような感じです。

Actionの準備

さてなにはともあれActionとStateの準備です。
Actionは、上記の通りStart、Success、Failureの3種類を用意しておきます。
Stateは、fetch中なのかどうか、うけとったデータ、失敗したのであればエラーメッセージを持つようにしています。

/src/modules/Types/Api.ts
import { Action } from 'redux';

export interface StartFetchAction extends Action {
    type: 'START_FETCH';
}

export type FailureFetchPayload = {
    message: string;
};

export interface FailureFetchAction extends Action {
    type: 'FAILURE_FETCH';
    payload: FailureFetchPayload;
}

export type ReceiveFetchPayload = {
    [key: string]: string;
};

export interface ReceiveFetchAction extends Action {
    type: 'RECEIVE_FETCH';
    payload: ReceiveFetchPayload;
}

export type ApiActions = StartFetchAction &
    FailureFetchAction &
    ReceiveFetchAction;

export type ApiState = {
    onFetch: boolean;
    error?: string;
    data: {
        [key: string]: string;
    };
};
/src/modules/Types/index.ts
import { CounterState, CounterActions } from './Counter';
import { ControllerState, ControllerActions } from './Controller';
+ import { ApiState, ApiActions } from './Api';

export {
    SetEnablePayload,
    ControllerActions,
    ControllerState,
} from './Controller';

export {
    IncrementPayload,
    DecrementPayload,
    CounterState,
    CounterActions,
} from './Counter';

+ export {
+    FailureFetchPayload,
+    ReceiveFetchPayload,
+    ApiActions,
+    ApiState,
+ } from './Api';

export type RootState = {
    counter: CounterState;
    controller: ControllerState;
+    api: ApiState;
};

- export type RootActions = CounterActions & ControllerActions;
+ export type RootActions = CounterActions & ControllerActions & ApiActions;

Thunk Actionをつくる

そしてAction Creatorです。
通常のActionCreatorについては、いつもと同じですし、ThunkActionのAction Creatorは先程のAsyncIncrement/AsyncDecrementと基本は同じです。
fetch部分は、せっかくなのでtry/catchasync/awaitを使って書いてみます。

/src/modules/Actions/Api.ts
import { ActionCreator, Dispatch, Action } from 'redux';
import {
    RootActions,
    FailureFetchPayload,
    ReceiveFetchPayload,
    RootState,
} from '../Types';
import { ThunkAction } from 'redux-thunk';

export const startFetch: ActionCreator<RootActions> = (): RootActions =>
    ({ type: 'START_FETCH' } as RootActions);

export const failureFetch: ActionCreator<RootActions> = (
    payload: FailureFetchPayload
): RootActions => ({ payload, type: 'FAILURE_FETCH' } as RootActions);

export const receiveFetch: ActionCreator<RootActions> = (
    payload: ReceiveFetchPayload
): RootActions => ({ payload, type: 'RECEIVE_FETCH' } as RootActions);

export const getData = (): ThunkAction<
    void,
    RootState,
    undefined,
    RootActions
> => async (dispatch: Dispatch<Action>) => {                 // 非同期な関数を使える
    dispatch(startFetch());                                  
    try {
        const resp = await fetch('https://api.github.com');
        const body = await resp.json();
        dispatch(receiveFetch(body));
    } catch (e) {
        dispatch(failureFetch({ message: e.message }));
    }
};
/src/modules/Actions/index.ts
import {
    increment,
    decrement,
    asyncIncrement,
    asyncDecrement,
} from './Counter';
+ import { getData } from './Api';

export const actionCreator = {
    counter: {
        increment,
        decrement,
        asyncIncrement,
        asyncDecrement,
    },
+    api: {
+        getData,
+    },
};

これが基本の形です。こうしておくことで、コンポーネント側では、ローディング中にロード画面を挟んだりとかができます。

残りの部分をなんとかする

まず、Reducerから片付けましょう。

/src/modules/Reducers/Api.ts
import { ApiState, ApiActions } from '../Types';

export const apiReducer = (
    state: ApiState = { onFetch: false, data: {}, error: 'Please fetch.' },
    action: ApiActions
) => {
    switch (action.type) {
        case 'START_FETCH':
            return Object.assign({}, state, { onFetch: true });
        case 'FAILURE_FETCH':
            return Object.assign({}, state, { error: action.payload.message });
        case 'RECEIVE_FETCH':
            return Object.assign({}, state, {
                onFetch: false,
                data: action.payload,
                error: null,
            });
        default:
            return state;
    }
};
/src/modules/Reducers/index.ts
import { combineReducers } from 'redux';
import { RootState, RootActions } from '../Types';
import { counterReducer } from './Counter';
import { controllerReducer } from './Controller';
+ import { apiReducer } from './Api';

export const rootReducer = combineReducers<RootState, RootActions>({
    counter: counterReducer,
    controller: controllerReducer,
+    api: apiReducer,
});

最後にコンポーネントです。

/src/component/FetchController.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { RootState, actionCreator, RootActions } from '../modules';
import { ThunkDispatch } from 'redux-thunk';

type OutterProps = {
    label: string;
};

type StateProps = {
    disabled: boolean;
};

type DispatchProps = {
    onClick: () => void;
};

type Props = OutterProps & StateProps & DispatchProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            <button
                onClick={_ => {
                    props.onClick();
                }}
                disabled={props.disabled}
            >
                {props.label}
            </button>
        </div>
    );
};

const mapStateToProps = (
    state: RootState,
    _ownProps: OutterProps
): StateProps => ({
    disabled: state.api.onFetch,
});

const mapDispatchToProps = (
    dispatch: ThunkDispatch<RootState, undefined, RootActions>,
    _ownProps: OutterProps
): DispatchProps => ({
    onClick: () => {
        dispatch(actionCreator.api.getData());
    },
});

export default connect<StateProps, DispatchProps, OutterProps, RootState>(
    mapStateToProps,
    mapDispatchToProps
)(component);
/src/component/DataArea.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { RootState } from '../modules';

type StateProps = {
    error?: string;
    data: {
        [key: string]: string;
    };
};

type Props = StateProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <div>
            {props.error == null ? (
                <ul>
                    {Object.entries(props.data).map(e => (
                        <li key={e[0]}>{e[1]}</li>
                    ))}
                </ul>
            ) : (
                <span>{props.error}</span>
            )}
        </div>
    );
};

const mapStateToProps = (
    state: RootState
): StateProps => ({
    error: state.api.error,
    data: state.api.data,
});

export default connect<StateProps, {}, {}, RootState>(
    mapStateToProps
)(component);

この例では、Controller部分とView部分をわけていますが、今回のケースであればひとつにすることもできますね。

できあがったソース群

まとめ

一度書いてみれば、ああこんなものかと思えますね。Reducerに到着するまでの経路でDispatchをフックすることで、処理を挟んだりActionをまとめて実行したりすることができるというふうに考えると理解しやすいですね。また、ここでは紹介しませんでしたが、一定の条件下で何もしなければDispatchしたけど何も起きないみたいなこともできます。

また、Redux Thunkは、Redux SagaやRedux Observableと比べて、実装がめちゃくちゃ少ないので、そもそも中で何をやっているかが非常に分かりやすく利用のストレスが少ないです。ActionCreatorの治安維持に、それなりに骨が折れるのは事実ですが。

getStateを通じて、Stateからデータも取り放題ですので、なんでmergeDispatchToPropsでStateが参照できないんだ!という叫びにも一定の救いを与えている良いライブラリだと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41