絶対にESLintに型推論してもらえる書き方を考えた | JS+React+Redux
はじめに
React+ReduxをJavaScriptで書いている時に苦労することは、データの型や形についてです。React+Reduxでは、以下のような部品間で同じ型や形を把握しておかなければなりません。例えば
・Reducer, Selector → state
・ActionCreator, Reducer → payload
・Component, Selector → Selectorの返す型
・Component, Operation → Operationの引数
色んなファイルを行き来して型や形が正しいか見ないといけないのはかなり疲れる。Javaとかだと静的な型を作るのでこういうことで悩まされることはないんですがね・・・。
今回は、JavaScriptでこういうのを全て型推論してもらえるような書き方を見つけたのでメモしておきます。
src内のディレクトリ構成
components
Reactのコンポーネントを入れていきます。
例えば App.js は起動して最初に登場するコンポーネントです。
modules
モジュールとモジュールをまとめる reducer 及び store を入れておきます。を入れていきます。モジュールとは、state の各場所の読み書きを責務として持つ関数群です。
re-ducksパターンでいう"types" "actions" "reducers" の働きを持ちます。
例えば memos.js はユーザーが作成したメモを保存したり読み出したり削除したりします。
presentations
componentsとmodulesをつなぐ橋渡しになります。非同期処理やその他複雑な処理を受け持ちます。1つのコンポーネントにつき1つ用意します。
re-ducksパターンでいう "selector" "operations" の働きを持ちます。MVPパターンで言うとPresenterに相当するような存在だなと思ったのでこのようなディレクトリ名にしました。
例えば App.js は コンポーネントのApp.jsを担当します。
作るもの
メモ帳みたいなのを作ります。
メモを書いて追加ボタンを押すと一覧表にそれが追加されます。ついでにIDも見えます(見えなくていいかもしれないけど)。メモの編集はできません。ゴミ箱ボタンを押すと消えます。
これが作ったものです↓
実装していく
実装中、JSDocを用いる所には📌
その他のアクションには🙄
型推論の恩恵を得られる所には🤩
を書いていきますね。
modules.memos
ここでは作るものが4つあります。
①初期state
②stateのゲッター
③ActionCreator
④Reducer
①初期state
📌JSDocを用いて各変数の説明や型を書きます。
const initialState = {
/**@type {string[]}*/
ids: [],
/**@type {Object<string,{id:string,content:string}>} */
entities: {}
};
②stateのゲッター
🙄stateの各変数は加工せずそのままの形で取り出します。この例ではメモの情報が正規化されてますが、正規化されたまま取り出します。
📌JSDocを用いて各関数の戻り値を書きます。ここに書くことは①初期stateで書いたものと同じになるはずです。
🙄最後に、エクスポートします。
export const memosGetter = {
/**@return {string[]}*/
getIds: (state) => state.memos.ids,
/**@return {Object<string,{id:string,content:string}>}*/
getEntities: (state) => state.memos.entities
};
③ActionCreator
🙄Redux Toolkit の createAction を用います。
📌この際、actionCreatorの第2引数にて prepare(Action.payloadを作る関数) を作り、JSDocでその引数の型を書きます。
🙄これをオブジェクト形式にまとめ、エクスポートします。
export const memosAction = {
addMemo: createAction(
"memos/addMemo",
/**
* @param {string} id
* @param {string} content
*/
(id, content) => ({ payload: { id, content } })
),
removeMemo: createAction(
"memos/removeMemo",
/**
* @param {string} id
*/
(id) => ({ payload: id })
)
};
④Reducer
🙄Redux Toolkit の createReducer を用います。
🤩action.payloadまで打つと ③ActionCreator で書いたJSDocが働き、payloadの中身が分かります!
🤩stateまで打つと ①初期state で書いたJSDocが働き、stateの中身が分かります!
🙄これをエクスポートします。
export const memosReducer = createReducer(initialState, (builder) => {
builder
.addCase(memosAction.addMemo, (state, action) => {
const { id, content } = action.payload; //🤩
state.ids.push(id);
state.entities[id] = { id, content };
})
.addCase(memosAction.removeMemo, (state, action) => {
const removingId = action.payload;
state.ids = state.ids.filter((id) => id !== removingId);
delete state.entities[removingId];
});
});
modules.その他
🙄その他は単純なものだけです。
reducer.js
import { combineReducers } from "redux";
import { memosReducer } from "./memos";
export default combineReducers({
memos: memosReducer
});
store.js
import { configureStore } from "@reduxjs/toolkit";
import reducer from "./reducer";
const store = configureStore({
reducer
});
console.log(store.getState());
export default store;
presenters.App
ここでは作るものが2つあります。
①Selector
②Operation
①Selector
🙄modules の ②stateのゲッター で得たものを加工してコンポーネントが表示できるようにします。この例ではメモの情報が正規化されて保存されていますので、1つの配列にまとめます。
🤩ゲッターで state の値を取り出すと ②stateのゲッター で書いたJSDocが働き、その型が見えます!!
const denormalizeMemos = (state) => {
const ids = memosGetter.getIds(state);
const entities = memosGetter.getEntities(state);
return ids.map((id) => entities[id]);//🤩
};
export const AppSelector = {
getMemos: (state) => denormalizeMemos(state)//🤩
};
②Operation
🙄コンポーネントが呼び出します。それを受けて必要な処理や ActionCreator の呼び出しを行い、Actionやthunkを返します。
🤩ActionCreator を呼び出す際、modules の ③ActionCreator で書いたJSDocが働き、引数の型が見えます!
export const AppOperation = {
addMemo: (content) => {
const id = v4();
return memosAction.addMemo(id, content);//🤩
},
removeMemo: (id) => {
return memosAction.removeMemo(id);//🤩
}
};
components.App
コンポーネントではありますがpropsを受け取りません。代わりに useSelector と useDispatch を使います。
🤩useSelectorで得た値も型推論が働きます!!これは元を辿っていくと module の ②stateのゲッター で書いたJSDocに由来しています。
dispatch中に書く各OperationもObject型でまとめているので、関数名が一覧に出てきます。
import {
Box,
List,
ListItem,
Toolbar,
ListItemText,
TextField,
Button,
Paper,
ListItemSecondaryAction,
IconButton,
} from "@material-ui/core";
import { useState } from "react";
import { Delete as DeleteIcon } from "@material-ui/icons";
import { useDispatch, useSelector } from "react-redux";
import { AppSelector, AppOperation } from "../presenters/App";
const showMemo = (content, id, handleRemoveMemo) => (
<ListItem key={id}>
<ListItemText primary={content} secondary={id} />
<ListItemSecondaryAction>
<IconButton edge="end" onClick={() => handleRemoveMemo(id)}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
const NewMemoEditor = (handleAddMemo) => {
const [content, setContent] = useState("");
return (
<Paper style={{ position: "sticky", top: "auto", bottom: 0 }}>
<Toolbar>
<TextField
label="ここにメモを書く"
style={{ flex: 4 }}
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<Button
style={{ flex: 1 }}
onClick={() => {
handleAddMemo(content);
setContent("");
}}
>
追加
</Button>
</Toolbar>
</Paper>
);
};
export default () => {
const memos = useSelector(AppSelector.getMemos);
const dispatch = useDispatch();
return (
<Box>
<List>
{memos.map((memo) =>
showMemo(memo.content, memo.id, (id) =>//🤩
dispatch(AppOperation.removeMemo(id))
)
)}
</List>
{NewMemoEditor((c) => dispatch(AppOperation.addMemo(c)))}
</Box>
);
};
最後に
1度JSDocを書くと色々な場所でその恩恵を受けられますね。特に modules で書いたJSDocが presenter → components の順番で伝播されていっているようです。型の伝播はこういう順番になっています。
また、関数を全てオブジェクト型でまとめることによって、関数名が分からなくなることもありません。
これで快適にReact+ReduxをJavaScriptで書けそうです。