はじめに
この記事を書いた動機
3年前に書いた記事に対して未だにちょこちょこと反応が来るものの、自分自身はもうこの方法ではやってないし、1年で大きく動くと言われるフロントエンド界隈で3年以上前の方法で学ばれるのもまずいと思い、知識の整理も込めて比較的新しい書き方で当該記事をリメイクしたい。
方針
- TypeScript を使う
- React と Redux と React-Thunk を使う部分は変えない
-
redux-starter-kit
を使って楽する
環境構築
npm install
npm init
した上で実行。
このへんに関してはあまり何も考えずに入れる。
# webpack系
npm install --save-dev webpack webpack-cli webpack-dev-server clean-webpack-plugin html-webpack-plugin
# React & Redux
npm install --save-dev react react-dom redux react-redux redux-starter-kit
# TypeScript
npm install --save-dev typescript tslint ts-loader @types/react @types/react-dom @types/react-redux
webpackの設定
src/index.tsx
をルートとして、ビルド時には build
以下にアセット群を吐き出すような場合は以下のように設定する。
html-webpack-plugin
は元となるテンプレートからHTMLを生成してくれる。
clean-webpack-plugin
はビルド生成先のディレクトリ配下にある不要なファイルを消してくれる。
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: __dirname + '/src/index.tsx',
plugins: [
new CleanWebpackPlugin({
cleanAfterEveryBuildPatterns:['build']
}),
new HtmlWebpackPlugin({
template: 'src/templates/index.html'
}),
],
output: {
path: __dirname + '/build',
filename: '[name].[contenthash].js'
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader' }
]
}
}
TypeScriptコンパイラの設定
この辺もあんまり考えることなく設定。
{
"compilerOptions": {
"sourceMap": true,
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"target": "es5",
"jsx": "react",
"lib": [
"dom",
"es6"
]
},
"include": [
"src"
],
"compileOnSave": false
}
tslint設定
tslint:recommend
を使った上で、コードを書いてるときに指摘されて「これはめんどいな…」と思ったものを rules
に追加して、無効にしたりカスタマイズしていくと良さそう。
{
"extends": ["tslint:recommended"],
"rules": {
"object-literal-sort-keys": false,
"ordered-imports": false,
"no-console": [true, "log"]
}
}
ビルド&開発用サーバー設定
前は grunt とか使ってたりしたけど、最近はそのへんをすべて webpack に集約させているので、滅茶苦茶複雑なビルドパイプラインとかが必要でない限りは大体 npm scripts
で事足りるようになった。
最終的な package.json
はこんな感じになる。
{
"name": "react-sample",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"clean": "rm -rf ./node_modules package-lock.json && npm i",
"build": "webpack -p",
"start": "webpack-dev-server -d --content-base ./public"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@types/react-redux": "^7.1.1",
"clean-webpack-plugin": "^3.0.0",
"html-webpack-plugin": "^3.2.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-redux": "^7.1.0",
"redux": "^4.0.4",
"redux-starter-kit": "^0.6.3",
"ts-loader": "^6.0.4",
"tslint": "^5.18.0",
"typescript": "^3.5.3",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.6",
"webpack-dev-server": "^3.8.0"
}
}
動作確認
テンプレート追加
<html lang="ja">
<body>
<div id="root"></div>
</body>
</html>
文字を表示するだけの tsx を追加
import React from "react";
import ReactDOM from "react-dom";
ReactDOM.render(
<h1>Hello World!</h1>,
document.getElementById("root"),
);
ビルドして表示する
下記のコマンドを叩くと webpack-dev-server
が起動するので、ブラウザで http://localhost:8080
にアクセスして Hello World!
が表示されれば準備完了。
コード差分を検知してホットリローディングされるので、開発する際には立ち上げっぱなしで良い。
npm start
open http://localhost:8080/
ToDoアプリ実装
ディレクトリ構成
いわゆる ducks
スタイルというものを採用。
Redux が出始めの頃は action
と reducer
でディレクトリを分ける感じだったが、最近はまとめて書くやり方のほうが主流っぽい。
React Component のうち、Redux とのやりとりをする containers
とそれ以外の components
に分けるという構成もあるのだが、小規模な構成のうちはとりあえず全部 components に突っ込んでしまっても良いと思う。
初期状態の src
ディレクトリ以下の構成はこういう感じ。
src
├── index.tsx
├── components/
├── modules/
└── templates/
moduleを実装
Redux の action と reducer の部分を実装する。
前半部分に interface を定義する部分が増えたものの、全体的なコード量は以前と比べて減ったのではないだろうか?
前のやり方との違いとして、state を変更する部分で Object.assign
などを使わなくても redux-starter-kit
側でいい感じにやってくれているので、直で state の値をいじっても良くなった(内部的にはImmerを使ってるらしい)。
reducers
として定義しているものを React 側からは taskModule.actions.addTask()
という風にして呼び出す。
また、非同期処理で redux-thunk
を使う場合は、こういう感じ(下記コードの saveTasks
を参照)で書くと良い。
import { createSlice, PayloadAction } from "redux-starter-kit";
export interface ITask {
id: number;
name: string;
isExecuted: boolean;
}
interface IEditTask {
id: number;
name: string;
}
interface ILoadingState {
isLoading: boolean;
isSuccess: boolean;
}
export interface IState {
addTask: IEditTask;
editTask: IEditTask;
tasks: ITask[];
saveState: ILoadingState;
}
const initialState: IState = {
addTask: {
id: 1,
name: "",
},
editTask: {
id: -1,
name: "",
},
tasks: [],
saveState: {
isLoading: false,
isSuccess: false,
},
};
const taskModule = createSlice({
initialState,
reducers: {
setAddTaskName: (state: IState, action: PayloadAction<string>) => {
state.addTask.name = action.payload;
},
setEditTaskById: (state: IState, action: PayloadAction<number>) => {
const task = state.tasks.find((t) => t.id === action.payload);
if (!task) {
return;
}
state.editTask = task;
},
setEditTaskName: (state: IState, action: PayloadAction<string>) => {
state.editTask.name = action.payload;
},
addTask: (state: IState) => {
if (!state.addTask.name) {
return;
}
state.tasks.push({
id: state.addTask.id,
name: state.addTask.name,
isExecuted: false,
});
state.addTask.id = state.addTask.id + 1;
state.addTask.name = "";
},
editTask: (state: IState) => {
const task = state.tasks.find((t) => t.id === state.editTask.id);
if (!task) {
return;
}
task.name = state.editTask.name;
state.editTask.id = -1;
},
endTask: (state: IState, action: PayloadAction<number>) => {
const task = state.tasks.find((t) => t.id === action.payload);
if (!task) {
return;
}
task.isExecuted = true;
},
setSaveState: (state: IState, action: PayloadAction<ILoadingState>) => {
state.saveState = action.payload;
},
},
});
export const { actions: taskActions } = taskModule;
export default taskModule;
export const saveTasks = () => {
return async (dispatch, getState) => {
const { task } = getState();
if (task.saveState.isLoading) {
return;
}
dispatch(taskActions.setSaveState({isLoading: true, isSuccess: false}));
// dummy
setTimeout(() => {
dispatch(taskActions.setSaveState({isLoading: false, isSuccess: true}));
}, 1000);
};
};
あと hooks で state を引っ張ってくるときに必要になるので、modules 配下にある state の interface はまとめておく。
import { IState as TaskState } from "./taskModule";
export interface IRootState {
task: TaskState;
}
storeを作る
正確には、reducer をまとめた rootReducer
と、store を生成するための setupStore()
を作る。
import { combineReducers, configureStore, getDefaultMiddleware } from "redux-starter-kit";
import taskModule from "./modules/taskModule";
const rootReducer = combineReducers({
task: taskModule.reducer,
});
export const setupStore = () => {
const middleware = getDefaultMiddleware();
return configureStore({
reducer: rootReducer,
middleware,
});
};
componentを作る
props経由でリスト表示する component の例。
上記で実装した actions は useDispatch()
経由で呼び出す。
import React from "react";
import { useDispatch } from "react-redux";
import { ITask } from "../modules/taskModule";
import { taskActions } from "../modules/taskModule";
const TodoList: React.FC<{ list: ITask[], isEndTasks: boolean }> = ({ list, isEndTasks }) => {
const dispatch = useDispatch();
const endTask = (task: ITask) => dispatch(taskActions.endTask(task.id));
const editTask = (task: ITask) => dispatch(taskActions.setEditTaskById(task.id));
const targetList = list.filter((task) => {
if (isEndTasks) {
return task.isExecuted;
} else {
return !task.isExecuted;
}
});
if (list.length < 1) {
return null;
}
return (
<table>
<tbody>
<tr>
<th>ID</th>
<th>Name</th>
<th>Actions</th>
</tr>
{targetList.map((task) => {
return (
<tr key={task.id}>
<td>{task.id}</td>
<td>{task.name}</td>
<td>
<button onClick={() => editTask(task)} disabled={isEndTasks}>Edit</button>
<button onClick={() => endTask(task)} disabled={isEndTasks}>End</button>
</td>
</tr>
);
})}
</tbody>
</table>
);
};
export default TodoList;
index.tsxで各componentとstoreを接続する
本当は form の部分とか諸々を components に移したほうが良いのだが、横着して index に置いている。
setupStore()
を呼び出し <Provider store={store}><App /></Provider>
とする部分は前とあまり変わってない。
state の取り出し方は hooks によって大きく変わった部分だが、書いてあるように useSelecter()
で取り出したい state を選択するだけで取ってこれるのでそんなに難しくないと思う。
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { setupStore } from "./store";
import { IRootState } from "./modules";
import TodoList from "./components/todo_list";
import { taskActions, saveTasks } from "./modules/taskModule";
const store = setupStore();
const App: React.FC = () => {
const dispatch = useDispatch();
const taskState = useSelector((state: IRootState) => state.task);
const onChangeTaskName = (e) => dispatch(taskActions.setAddTaskName(e.target.value));
const onChangeEditTaskName = (e) => dispatch(taskActions.setEditTaskName(e.target.value));
const onClickAdd = (e) => {
if (e) {
e.preventDefault();
}
dispatch(taskActions.addTask());
};
const onClickEdit = (e) => {
if (e) {
e.preventDefault();
}
dispatch(taskActions.editTask());
};
const onClickSave = () => dispatch(saveTasks());
return (
<>
<h1>Todo</h1>
<h2>Add Task</h2>
<form onSubmit={onClickAdd}>
<input type="text" value={taskState.addTask.name} onChange={onChangeTaskName} />
<button onClick={onClickAdd}>Add</button>
</form>
<h2>Current List</h2>
<TodoList list={taskState.tasks} isEndTasks={false} />
{taskState.editTask.id > -1 ? (
<>
<h2>Edit Task</h2>
<form onSubmit={onClickEdit}>
({taskState.editTask.id}):
<input type="text" value={taskState.editTask.name} onChange={onChangeEditTaskName} />
<button onClick={onClickEdit}>Edit</button>
</form>
</>
) : null}
<h2>Executed List</h2>
<TodoList list={taskState.tasks} isEndTasks={true} />
<h2>Save</h2>
<button onClick={onClickSave} disabled={taskState.saveState.isLoading}>
{taskState.saveState.isLoading ? "Saving..." : "Save"}
</button>
<div style={{display: taskState.saveState.isSuccess ? "block" : "none", color: "#FF0000"}}>
Save Complete!
</div>
</>
);
};
ReactDOM.render(
<Provider store={store}><App /></Provider>,
document.getElementById("root"),
);
最終的なディレクトリ構造
src
├── index.tsx
├── store.ts
├── components/
│ └── todo_list.tsx
├── modules/
│ ├── index.ts
│ └── taskModule.ts
└── templates/
└── index.html