はじめに
reduxのAction周りの型をしっかり付けようとすると、どうしてもコード量が多くなってしまいます。
さらに、redux-sagaを使って副作用を実装する場合は、副作用ごとに request
、success
、failure
の3ケースについてActionとActionCreatorを定義する必要があるため大変です。
この悩みを解決するために、本稿ではtypesafe-actionsというライブラリを使って少ないコードでしっかり型をつける方法を紹介します。
題材にするコード
今回はYouTube Data APIを利用して、動画を検索できるクライアントアプリを題材に説明していきます。
コードはこちら
実装内容
今回は入力されたテキストから動画を検索する機能をredux-sagaを用いて実装していきます。
これをredux-sagaのフローに分けると以下のようになります。
- 検索ボックスにテキストを入力してsubmitしたらAPIを叩く(REQUEST)
- 正常に値を受け取れた場合、Storeに保存する(SUCCESS)
- APIとの通信に失敗した場合、エラーを投げる(FAILURE)
ActionType, ActionCreator, Actionの実装
まずはActionTypeを定義します。
enum VideoActionType {
SEARCH_VIDEOS_REQUEST = "SEARCH_VIDEOS_REQUEST",
SEARCH_VIDEOS_SUCCEEDED = "SEARCH_VIDEOS_SUCCEEDED",
SEARCH_VIDEOS_FAILED = "SEARCH_VIDEOS_FAILED",
}
export default VideoActionType;
続いて、ActionCreatorを実装します。ここで、typesafe-actions
の出番です。
createAsyncAction
を利用することで、簡単にActionCreatorを定義できます。
import VideoActionType from './VideoActionType';
import SearchListResponse from '../../states/SearchListResponse';
import { createAsyncAction } from 'typesafe-actions';
export const searchVideos = createAsyncAction(
VideoActionType.SEARCH_VIDEOS_REQUEST,
VideoActionType.SEARCH_VIDEOS_SUCCEEDED,
VideoActionType.SEARCH_VIDEOS_FAILED
)<string, SearchListResponse, Error>();
以下の3つのActionCreatorをまとめて生成することができました。
searchVideos.request(payload: string)
searchVideos.success(payload: SearchListResponse)
searchVideos.success(payload: Error)
それぞれのメソッドは、引数で指定したtype
とジェネリクスで指定した型のpayload
を返します。payload
がいらない場合はジェネリクスの該当部分にundefined
を指定しましょう。
素のTypeScriptだけで実装する場合、以下のように1つずつ定義する必要があるので、だいぶ楽ですね。
function request(payload: string) {
return {
type: VideoActionType.SEARCH_VIDEOS_REQUEST,
payload,
};
}
function success(payload: SearchListResponse) {
return {
type: VideoActionType.SEARCH_VIDEOS_SUCCEEDED,
payload,
};
}
function failure(payload: Error) {
return {
type: VideoActionType.SEARCH_VIDEOS_FAILED,
payload,
};
}
最後にActionCreatorの戻り値のActionオブジェクトも一つの型にまとめましょう。
ここではtypesafe-actions
のActionType
を使うことでsearchVideos
が持つ3つのActionCreatorの戻り値のActionオブジェクトを1つのunion型にまとめることができます。
この型は、Reducerの引数action: VideoAction
やcontainerのmapDispatchToPropsの引数dispatch: Distatch<VideoAction>
のように使用します。
import { ActionType } from "typesafe-actions";
import * as ActionCreators from "./VideoActionCreator";
type VideoAction = ActionType<typeof ActionCreators>;
export default VideoAction;
sagaの実装
searchVideosSaga
はsearchVideos.request(payload: string)
が呼び出されると起動するジェネレータ関数です。この部分は最後の行のtakeLatest
で設定しています。引数でrequestの戻り値のActionオブジェクトを受け取ります。
action
の型の指定はTypeScriptのReturnType
を使います。今回の場合action: PayloadAction<VideoActionType.SEARCH_VIDEOS_REQUEST, string>
ですが、ActionCreatorの引数が無い場合の型はEmptyAction<...>
になるため、直接指定するのはナンセンスだからです。
さらに、yield
の戻り値は型がany
になってしまうため、yield call()
で呼び出すapi.search
の戻り値の型を自分で指定する必要があります。
api.search
の型は、YouTubeApi.search(q: string): Promise<SearchListResponse | undefined>
なため、ReturnType
でPromise
以下を取り出し、さらに下記のPromiseGenericType
でPromise
のジェネリクス部分の型(SearchListResponse | undefined
)を取り出しています。
export function* searchVideosSaga(
action: ReturnType<typeof VideoActionCreators.searchVideos.request>
) {
const searchListResponse: PromiseGenericType<ReturnType<typeof api.search>> = yield call(
api.search,
action.payload
);
if (searchListResponse) {
yield put(VideoActionCreators.searchVideos.success(searchListResponse));
} else {
yield put(
VideoActionCreators.searchVideos.failure(new Error('searchListResponse is undefined'))
);
}
}
export const videoSagas = [takeLatest(VideoActionType.SEARCH_VIDEOS_REQUEST, searchVideosSaga)];
export type PromiseGenericType<T> = T extends Promise<infer U> ? U : T;
その他
Redux storeのRootStateの定義
StateType
を使うことでreducerからRoot Stateの型を定義することができます。
手動でRootStateを定義するとreducerを更新するたびに変更しなければいけないため、バグに繋がってしまうので、これを使うのがおすすめです。
import { StateType } from 'typesafe-actions';
import rootReducer from 'src/reducers';
type RootState = StateType<typeof rootReducer>;
export default RootState;
RootState
はcontainerで以下のように使うと型推論が効いてミスを無くすことができます。
import RootState from 'src/states';
import VideoList from 'src/components/VideoList/VideoList';
import { VideoListConnectedProps } from 'src/components/VideoList/VideoListProps';
import { connect } from 'react-redux';
const mapStateToProps = (state: RootState): VideoListConnectedProps => ({
searchListResponse: state.searchListResponse,
});
export default connect(
mapStateToProps,
null
)(VideoList);
おわりに
typesafe-actions
には他にも便利なユーティリティがたくさんあるので、ぜひ使ってみてください。