手持ちのredux-observableのboilerplateをTypeScript+Hooks化して公開した時の話。
何か開発を始める時に度々使っていたredux-observableのboilerplateがちょっと古くなってきたので、この際TypeScript化してHooksも使うようにしてrxjsも5系から6系に上げよう!と思いたち、なんとか動くところまで持っていけたので、同じような事をしようとしてる人達と自分の振り返りのために記事として残すことにしました。
ちなみに成果物はこれ↓
https://github.com/kunihiko-t/redux-observable-ts-hooks-boilerplate
フルスクラッチで1つ1つ設定など書いていくのが面倒だったので
create-react-app my-app --typescript
してからejectしました。
typescript-eslintなどの設定はこちらを参考にさせていただきました。(実際のところ全部終わってから追加しましたが)
http://watanabeyu.blogspot.com/2019/02/typescript-eslinttypescriptlinteslintai.html
そして既存のファイルを*.js -> *.ts, *.jsx -> *.tsx にしてとりあえず突っ込んでいきました。
コンポーネント周りなど普通にesで書いてると型定義とかもちろんないのでエラーが出まくります。
例えばこんな感じで書いてるとitemsのlengthがないぞとかで怒られると思います。
const repositoryList = ({ items, total_count, isLoading }) => {
const totalCountElem = items.length > 0 ? (<div>Total Count: {total_count}</div>) : (<></>)
........
このように全てのパラメータに型を定義していきます。
const repositoryList: React.FC<{ items: GithubRepository[], total_count: number, isLoading: boolean }> = ({ items, total_count, isLoading }) => {
......
}
最初はめんどくさいなーとか思ってやってましたが、ちゃんと型定義することでPropTypesとか書かなくて良くなり無駄なコードが減ってエディタの補完にも優しくフロントでも型あったほうが良いなぁと思うようになりました。
だいたいの新しめのライブラリには型定義があり、 yarn add @types/ライブラリ名
でインストールできます。
ただ、新しすぎるライブラリや古いライブラリなどは型定義がないものもあるようで、今回Hooksのために入れた react-redux 7.1.0も型定義がありませんでした。
yarn add @tyes/[email protected]
とかやってみると、指定されたバージョンの型定義がないからリストから選べって言われてしまいます。
どうしたもんかなと思ったんですが、ライブラリに型定義がない場合は自分で追加することもできるらしく、tsconfig.jsonのtypeRootsプロパティに設定されたディレクトリ以下にreact-redux.d.ts
みたいなファイルを作って型定義を行うことで型定義がない関数なども利用可能になるようです。
自分の場合はtypeRootsの定義がこんな感じなので
"typeRoots": [
"node_modules/@types",
"src/types"
],
src/types/react-redux.d.ts
を作りそこに型定義を書きました。
https://github.com/kunihiko-t/redux-observable-ts-hooks-boilerplate/blob/master/src/types/react-redux.d.ts
せっかくTypeScript化したのだからその恩恵をもっと受けようと思いtypescript-fsaを導入しました。
これの何が嬉しいのかというと、これが↓
import keyMirror from 'fbjs/lib/keyMirror';
export const ActionTypes = keyMirror({
USER_LOGIN_REQUEST: undefined,
USER_LOGIN_SUCCESS: undefined,
USER_LOGIN_FAILURE: undefined,
})
export function login() {
return {
type: ActionTypes.USER_LOGIN_REQUEST,
payload: { id: 'test' },
};
}
こうなり、
const keyMirror = require('fbjs/lib/keyMirror')
export const ActionTypes = keyMirror({
USER_LOGIN: undefined,
})
import actionCreatorFactory from 'typescript-fsa'
const ac = actionCreatorFactory()
interface LoginParam {
id: string
}
interface LoginResult {
data: any
}
interface LoginError {
error: string
}
export default {
login: ac.async<LoginParam, LoginResult, LoginError>(ActionTypes.USER_LOGIN),
}
typescript-fsa-reducers
を併せて使うことで
reducerもこんな感じで書けるようになってコードとてもスッキリします。
import immutable from 'immutability-helper'
import { reducerWithInitialState } from 'typescript-fsa-reducers'
import actions from '../actions/user'
export const userState = {
isAuthenticated: false,
status: 'idle',
}
export default {
user: reducerWithInitialState(userState)
.case(actions.login.started, (state, action) => {
return immutable(state, {
status: { $set: 'running' },
})
})
.case(actions.login.done, (state, action) => {
return immutable(state, {
status: { $set: 'idle' },
isAuthenticated: { $set: true },
})
})
.case(actions.login.failed, (state, action) => {
return immutable(state, {
status: { $set: 'running' },
isAuthenticated: { $set: false },
})
}) ........
今回はredux-observableも利用するのでtypescript-fsa-redux-observableも導入しました。 こちらを利用することで
ofAction(actions.login.started)
のようにepicを書くことができてとても嬉しいのですが、1点注意事項があって、yarn add typescript-fsa-redux-observable
でインストールできるtypescript-fsa-redux-observableはちょっと古いようで、githubのrepositoryにあるREADMEのような記述をするにはyarn add https://github.com/m0a/typescript-fsa-redux-observable
と直接githubのrepositoryを指定する必要がありました。
自分が使っていたrxjsが5系だったので、ついでだから6系に上げようと思ったら結構仕様が変わっていて、さらにTypeScript化することもあいまってなかなか大変でした。
rxjs5系では . で繋げてこんな感じで書いていたのですが
export function fetchRepositories(action$) {
return action$
.ofType(ActionTypes.FETCH_REPOSITORIES_REQUEST)
.delay(1000)
.mergeMap(param =>
Observable.defer(() =>
Observable.fromPromise(
axios.get(
`${ApiBaseUrl}/github/repositories?installation_id=${
param.payload.installationID
}`
)
)
).map(data => {
return {
type: ActionTypes.FETCH_REPOSITORIES_SUCCESS,
payload: { response: data.data },
};
})
)
.catch(error =>
Observable.of({
type: ActionTypes.FETCH_REPOSITORIES_FAILURE,
payload: { error },
error: true,
})
);
}
rxjs6 + typescript-fsa-redux-observable ではこんなノリになります。(ちょっと上記と内容が違いますが)
pipeで繋げる感じになって、method chainで呼び出すのではなく、関数を個別で呼び出す感じです。
export const fetchRepositories: Epic<AnyAction> = (action$) => action$.pipe(
ofAction(actions.fetchRepositories.started),
debounceTime(1000),
mergeMap((param) =>
ajax.getJSON(`https://api.github.com/search/repositories?q=+language:javascript+created:%3E2016-10-01&sort=stars&order=desc`).pipe(
map(data => {
return actions.fetchRepositories.done({
params: param.payload,
result: { repositories: data },
})
}),
catchError(error =>
Observable.of(actions.fetchRepositories.failed({
params: param.payload,
error: error,
})),
),
),
),
)
https://github.com/kunihiko-t/redux-observable-ts-hooks-boilerplate/blob/master/src/epics/github.ts
コードはすっきりしてこの書き方のほうが好きなんですが、型エラーがでた時にエラーメッセージがすさまじく長くて一体どこの型がどうマズイのか非常に分かりにくくて辛かったです。
React側のHooks使うんだから、Reduxでも用意されてるHooksを使いたいと思い、対応している7.1.0-rc.1も出ていたので使っていくことにしました。(この記事執筆時点で丁度7.1.0が出ましたが型定義は7.0.9のまま。。)
導入は割と楽にできて、connect
やmapStateToProps
が不要になり、代わりにuseSelector
を使うようにしたり、propsから取ってきていたdispatchをuserDispatch
から取得したりするだけで簡単に移行できました。
コードを見たほうが早いと思います。 https://github.com/kunihiko-t/redux-observable-ts-hooks-boilerplate/blob/master/src/routes/Home.tsx
epic周りのエラーを修正している時が一番しんどかったです。
React ReduxのHooksやtype-script-fsaのおかげでコードがシンプルになり、TypeScriptのおかげでエディタの補完機能がいい感じに効くようになるのはかなり良いなぁと思いました。
boilerplate程度の規模だとちょいちょい時間を取って数日もあればできるのでやって良かったなぁという感じです。
最近next.jsでnext export
という機能があると知ったので次はnext.jsで動くようにしたいと思っています。
あと、webpackのalias設定うまくいってないのでそこをどうにかしたい。 TypeScriptだとちょっと勝手が違うぽい。