この記事は TypeScript Advent Calendar 2018 の14日目です。
業務でReact+Reduxでフロントエンドの実装をした際に型がないと辛かったので、
TypeScriptを導入し、試行錯誤の末に落ち着いた書き方を晒します。
ReactやReduxの実装方法自体ではなく、如何に型安全に実装するかに焦点を当ててまとめます。
はじめに
盛大に防壁を展開しておきます。
- 私はフロントエンド専業ではありません。(サーバサイド寄り)
- 実装してきたフロントエンドのUIの規模も比較的小規模かと思います。紹介する実装方法は規模が大きくても適用できないことはないと思いますが、使用するミドルウェアの関係で今回の実装方法が難しい場合があるかもしれません。
- 職場環境的に周りにもTHEフロントエンドの人というのがおらず、TypeScriptの記法も見様見真似で書いているので、ご指摘あればよろしくお願いします。
方針
基本的にすべて型解決して書きたい
型がある方が心理的に安全な感じですし、anyで逃げるのは極力避けたいです。
型定義記述は楽したい
型解決できるようには書きたいですが、できるだけシンプルに、何度も一部重複した型定義を書くようなことはしたくないです。
あまり特別な書き方をしたくない
jsのコードをそのまま型付けするだけのプリミティブな実装が理想的と考えています。
Action/Reducer界面の型解決ができるtypescript-fsaのようなパッケージが提供されていますが(これが一般的?トレンド教えてほしい)、ReducerでisType
で判定しなければならずswitch文が書けなくなるなど、イマイチしっくり来ませんでした。
どこが問題になるか?
React+Reduxの実装で型解決する場合にネックになるのが以下の2点だと考えています。
① ReducerにおけるActionの型解決
② Containerにおけるconnectで渡されるpropsの型解決
前者は、Actionのtype名と実際のオブジェクトを関連付けるための何らかの仕組みがないと型解決できません。
後者についても、以下のような実装を行うと、connectから渡される実際のpropsの型と対象のコンポーネントのpropsの型定義の関連がなくなってしまいます。
これでは、型が不一致になっていても気づけません。
// ここの型定義を手書きすると型になんの保証もなくなる。
inteface StateProps {
// mapStateToPropsからもらうprops定義
}
interface DispatchProps {
// mapDispatchToPropsからもらうprops定義
get: ...,
post: ...,
}
// 対象コンポーネント
const App = (props: StateProps & DispatchProps) => (
...
)
export default connect(
(state: AppState) => ({ ...state }),
(dispatch: Dispatch) => ({
get: () => dispatch('GET'),
post: () => dispatch('POST')
})
)(App)
この記事では、上記2つの問題を解決する実装方法を説明していきたいと思います。
前提環境
説明用に作ったこちらのサンプル実装をベースにすすめます。
https://github.com/en-ken/react-redux-ts-example
内容は入力ダイアログを開き、値を入力するとリストに表示されるという非常に簡単なものです。
見た目も設計もだいぶ適当ですが、ご容赦。
表示される画面は全く同じものですが、3つの実装手段を試しています。
- Reduxミドルウェアを使わない実装
- redux-thunkを使った実装
- redux-observableを使った実装(typescript-fsaを使ったもの)
以降の説明に出てくるのは1と2についてになります。
以下、主なパッケージのバージョン記述を抜粋。
"@types/react": "^16.7.13",
"@types/react-dom": "^16.0.11",
"@types/react-redux": "^6.0.10",
"@types/redux": "^3.6.0",
=== 中略 ===
"react": "^16.6.3"
"react-dom": "^16.6.3",
"react-redux": "^6.0.0",
"redux": "^4.0.1",
"redux-observable": "^1.0.0",
"redux-thunk": "^2.3.0",
=== 中略 ===
"typescript": "^3.2.2"
① ReducerにおけるActionの型解決
Actionの共用体型定義(Union)をswitch文で如何に絞り込めるようにするかがキモになります。
Reducerで入ってきたActionをswitchで判定したあとに、必要に応じてActionのペイロードに型解決でアクセスできるように実装します。
Action
ActionはFSA(Flux Standard Action)モドキを使っています。typeのみ、または、typeおよびpayloadのみ存在するActionを定義しています。
export interface Action<Type extends string> {
type: Type
}
export interface ActionWithPayload<Type extends string, Payload> {
type: Type
payload: Payload
}
Action Creator
このAction
とActionWithPayload
の型を解決できるよう、以下のようなAction Creator の型定義および実装を行います。このcreateAction()
の定義によりType
とActionの型定義を関連付けけることができるので、switch文で絞り込めるようになります。
// 関数の型定義(Action用, ActionWithPayload用)
export function createAction<Type extends string>(type: Type): Action<Type>
export function createAction<Type extends string, Payload>(
type: Type,
payload: Payload
): ActionWithPayload<Type, Payload>
// 実装
export function createAction<Type, Payload>(type: Type, payload?: Payload) {
return payload ? { type, payload } : { type }
}
Action / Action Creator実装
上記で定義したcreateAction()
を使って、ActionおよびAction Creatorを実装します。
Reduxミドルウェアを導入しない場合
ミドルウェアを利用しない場合、createAction()
を利用してActionを返すAction Creatorメソッドをactions
内(名前がわかりづらいですね。。。)に定義します。
ここで、actions
内にすべてまとめているのは、用意したActionsUnion
というユーティリティによりAction Creatorの作るActionのUnionを簡単に抽出できるようにするためです。
// この記述のみでActionの共用体型定義を抽出できる。
export type AppAction = ActionsUnion<typeof actions>
const actions = {
fetchDataSuccess: (data: PersonalData[]) =>
createAction(ActionType.FETCH_DATA_SUCCESS, { data }),
closeDialog: () => createAction(ActionType.CLOSE_DIALOG),
openDialog: () => createAction(ActionType.OPEN_DIALOG),
startLoading: () => createAction(ActionType.START_LOADING),
finishLoading: () => createAction(ActionType.FINISH_LOADING)
}
以下が、ActionsUnion
の定義です。
actions
のようなオブジェクトにおいて、すべての項目の戻り値型のUnionを生成します。そのため、Action Creatorの戻り値であるActionのUnionが返されることになります。
export type ActionsUnion<
A extends {
[actionCreator: string]: (...args: any[]) => any
}
> = ReturnType<A[keyof A]>
redux-thunkを導入する場合
redux-thunkを使う場合は、副作用のある処理をdispatchする先に押し込めることになるため、actions
には以下のように、Action Creatorメソッドと、処理を行うメソッド(redux-thunkの定義でいうThunkAction
のCreatorメソッド)を一緒に実装します。
export type AppAction = ActionsUnion<typeof actions>
const actions = {
fetchData: () => async (dispatch: Dispatch) => {
dispatch(actions.startLoading())
const { data } = await PeopleApi.get()
dispatch(actions.fetchDataSuccess(data))
dispatch(actions.finishLoading())
},
fetchDataSuccess: (data: PersonalData[]) =>
createAction(ActionType.FETCH_DATA_SUCCESS, { data }),
postData: (inputData: PersonalData) => async (dispatch: Dispatch) => {
dispatch(actions.startLoading())
dispatch(actions.closeDialog())
await PeopleApi.post(inputData)
const { data } = await PeopleApi.get()
dispatch(actions.fetchDataSuccess(data))
dispatch(actions.finishLoading())
},
openDialog: () => createAction(ActionType.OPEN_DIALOG),
closeDialog: () => createAction(ActionType.CLOSE_DIALOG),
startLoading: () => createAction(ActionType.START_LOADING),
finishLoading: () => createAction(ActionType.FINISH_LOADING)
}
ActionsUnion
でActionのUnionを抽出するためには、処理を行うメソッドは邪魔になるので、ActionsUnion
の定義はミドルウェアなしの場合から変更する必要があります。
以下のように、戻り値を抽出した結果をExcludeで囲むことでThunkAction
を想定した関数を除外して、純粋なActionの型定義のみを抽出しています。
export type ActionsUnion<
A extends {
[actionCreator: string]: (...args: any[]) => any
}
> = Exclude<ReturnType<A[keyof A]>, (...args: any[]) => Promise<void>>
Reducer実装
Actionの取る値の型を定めた共用体型定義は抽出できたため、あとはReducerでswitch文を使ってTypeを判別すれば、自動的にActionの型を確定できるようになります。
const reducer = (state: AppState = initState, action: AppAction): AppState => {
switch (action.type) {
case ActionType.START_LOADING:
return {
...state,
isLoaded: false
}
/* ==中略== */
case ActionType.FETCH_DATA_SUCCESS:
return {
...state,
data: action.payload.data
}
default:
return state
}
}
標準的なのReducer実装を維持したまま型解決が可能になりました。
以下のように、VSCode上でも補完されてます。
#② Containerにおけるconnectで渡されるpropsの型解決
次に、Containerにおいて、connectで渡されるpropsが対象コンポーネントに渡される際に型を簡単かつ安全に対象コンポーネントに引き継ぐ方法を説明します。
まず、mapStateToPropsおよびmapDispatchToPropsを実装が以下です。
const mapStateToProps = (state: AppState) => ({ ...state })
const mapDispatchToProps = (dispatch: Dispatch) => ({
initialize: async () => {
dispatch(actions.startLoading())
const { data } = await PeopleApi.get()
dispatch(actions.fetchDataSuccess(data))
dispatch(actions.finishLoading())
},
postData: async (inputData: PersonalData) => {
dispatch(actions.startLoading())
dispatch(actions.closeDialog())
await PeopleApi.post(inputData)
const { data } = await PeopleApi.get()
dispatch(actions.fetchDataSuccess(data))
dispatch(actions.finishLoading())
},
openDialog: () => dispatch(actions.openDialog()),
closeDialog: () => dispatch(actions.closeDialog())
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(App)
実装自体は特に特殊なことはやっていませんが、ポイントはconnectの引数の中で記述せず、名前をつけて定義することです。
これは、以下のように参照したいからです。
type StateProps = ReturnType<typeof mapStateToProps>
type DispatchProps = ReturnType<typeof mapDispatchToProps>
type Props = StateProps & DispatchProps // & PropsFromParent, if needed.
この記述を行うことで、connectから渡されるpropsのUnionの定義を抽出できます。
ReturnTypeを使えば簡単に書けるということに気づくまで、いちいちStateProps
とDispatchProps
を手で定義しては型の不一致ミスを繰り返していました。。。
StateProps
とDispatchProps
が抽出できれば、あとは必要に応じて親からのpropsの型とANDを取れば対象コンポーネントに渡されるpropsの型定義が作成できます。
#まとめ
ということで、
① ReducerにおけるActionの型解決
② Containerにおけるconnectで渡されるpropsの型解決
の2つの問題の解決方法を紹介しました。
比較的実装のシンプルさを保ったまま型安全に書けていると思っていますがいかがでしょうか。
他に、もっといい方法があるよという情報ありましたら教えてください。
#蛇足①
今回のサンプルの例ではActionとしてFSAモドキを使用しており一般性がないものでしたが、FSAに準拠したActionが扱えれば一般性があるかなと思い、FSAで同じように型解決しながら記述ができるライブラリを作成しました。
よろしければお使いください。
#蛇足②
この記事には全く出てこなかったもののredux-observableを使って実装したサンプルも作りました。
redux-observableでは、epicsでの記述がswitch文ではないため今回の方法ではActionの型の絞り込みはできません。
なのでtypescript-fsa
+ typescript-fsa-redux-observable
という組み合わせで解決を試みたのですが、typescript-fsa-redux-observable
がRxJs v6の記述方法に対応していなかったので、最新のredux-observableで使えませんでした。
そこで、使用するofAction
のみv6準拠で以下のパッケージに書き直しました。
en-ken/typescript-fsa-redux-observable-of-action
無駄に長いパッケージ名ですみません。。。
こちらもよろしければお使いください。