はじめに
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の前にredux
とreact-redux
の簡単なおさらい
さて、本題に入る前にredux
とreact-redux
を使った簡単なカウンターを作ってみましょう。
まずは、StateとActionを作ります。Actionはインクリメントとデクリメントを用意しておきます。せっかくなので増減幅は外側から渡せるようにしておきます。Stateはカウンタの値を保持しておけばOKです。
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として、まとめておきます。
import { CounterState, CounterActions } from './Counter';
export {
IncrementPayload,
DecrementPayload,
CounterState,
CounterActions,
} from './Counter';
export type RootState = {
counter: CounterState;
};
export type RootActions = CounterActions;
次は、ActionCreatorです。
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です。
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
もここであらかじめしておきます。
import { combineReducers } from 'redux';
import { RootState, RootActions } from '../Types';
import { counterReducer } from './Counter';
export const rootReducer = combineReducers<RootState, RootActions>({
counter: counterReducer,
});
これで準備完了なので、modules
からexport
しておきます。
export { RootState, RootActions } from './Types';
export { actionCreator } from './Actions';
export { rootReducer } from './Reducers';
では最後に、これらをいわゆるContainer componentにconnect
しましょう。
まずはカウンター部分です。
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);
コントローラも作りましょう。
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を追加してみます。
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も更新しましょう。
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も更新します。
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からきちんと可・不可を取り出すように変更します。
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を使ってみます!
基本的には、「dispatch
とgetState
を受け取るような関数」を返す関数を作る感じです。dispatchを受け取っていますので、関数内では他のactionをディスパッチできます。
戻り値となる関数の戻り値型はThunkAction<R, S, E, A>
で、
- 第一引数がDispatchされたActionの戻り値型
- 第二引数がStateの型
- 第三引数は
dispatch
とgetState
の他にもうひとつ取れる引数の型 - 第四引数がActionの型です
外側から使うときは普通のAction Creatorと似たような感じで呼び出しますので、引数も当然受け取れます。
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);
};
import {
increment,
decrement,
+ asyncIncrement,
+ asyncDecrement,
} from './Counter';
export const actionCreator = {
counter: {
increment,
decrement,
+ asyncIncrement,
+ asyncDecrement,
}
};
コンポーネントにつないでみる
では、コンポーネントにつないでみましょう。
気をつけるのは、mapDispatchToProps
にはDispatch
ではなくThunkDispatch<S, E, A>
を渡すという部分だけで、それ以外は普通の場合と同じです。
- 第一引数がStateの型
- 第二引数は
dispatch
とgetState
の他にもうひとつ取れる引数の型 - 第三引数がActionの型です
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中なのかどうか、うけとったデータ、失敗したのであればエラーメッセージを持つようにしています。
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;
};
};
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/catch
とasync/await
を使って書いてみます。
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 }));
}
};
import {
increment,
decrement,
asyncIncrement,
asyncDecrement,
} from './Counter';
+ import { getData } from './Api';
export const actionCreator = {
counter: {
increment,
decrement,
asyncIncrement,
asyncDecrement,
},
+ api: {
+ getData,
+ },
};
これが基本の形です。こうしておくことで、コンポーネント側では、ローディング中にロード画面を挟んだりとかができます。
残りの部分をなんとかする
まず、Reducerから片付けましょう。
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;
}
};
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,
});
最後にコンポーネントです。
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);
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が参照できないんだ!という叫びにも一定の救いを与えている良いライブラリだと思います。