こんにちは、Morishing362と申します。
UI/UXエンジニアをしております。普段はTypeScript/Flutter/Rust/C++などを触っています。仕事以外でツールを作る機会があり、Reactでやってみたく、その際にベストプラクティスを作ってみたので晒します。ベースは公式チュートリアルに則って少しアレンジしました。
各フレームワークの役割
- React: フロントエンドフレームワーク
- Redux: Reactの状態管理
- Express: HTTPサーバーフレームワーク
#目標のアプリ
左側に比較的簡単な状態管理のカウンター機能、右側にデータベースを使った非同期処理を伴うユーザー追加機能を持つ、なんの意味もないアプリです。
#環境
- Yarn: 1.22.5
- node: 14系
- SQLite3クライアント: 3.31.1
npmやYarnのインストール手順は世の中にたくさんあるので省きます。
#忙しい人向け
色々と忙しいと思いますのでソースコード置いておきます。
- サーバーサイド: https://github.com/Morishing362/ExpressTutorial
- フロントエンド: https://github.com/Morishing362/ReduxTutorial
#サーバーサイド
###プロジェクトの作成
サーバーサイドから行きます!まずは作業ディレクトリ作成とその初期化から。
$ mkdir ExpressTutorial
$ cd ExpressTutorial
$ yarn init
TypeScriptをインストール。
yarn add @types/node typescript ts-node
express, sqlite3, corsをインストール。
$ yarn add express @types/express cors @types/cors sqlite3 @types/sqlite3
yarn dev
でサーバー起動するようにpackage.jsonにscriptsを以下のように追加(お好みで)。
{
...dependenciesとか...
...
"scripts": {
"dev": "ts-node ./src/index.ts"
}
}
###サーバーサイド接続テスト
Expressを使ってテストします。src/index.tsに以下のように書きます。
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.send('Connection Success');
});
app.listen(4001, () => {
console.log('The server is listening on port 4001');
});
そしてコンソールでyarn dev
と入力して、サーバーが立ち上がるはずです。
$ yarn dev
yarn run v1.22.5
$ ts-node ./src/index.ts
The server is listening on port 4001
あとはcurlなりブラウザなりで、http://localhost:4001/ にアクセスしてみてください。Connection Success!が出れば成功です。
###目標の構成
それでは本格的にExpressとSQLite3データベースを使ってユーザー読み込み、登録、削除機能を作っていきましょう。今回はテスト駆動開発ではなく、テストコードの書き方などは省きます。構成は以下の通り。
src
|-- db
| `-- database.sqlite3
|-- controllers.ts // データベース読み書き
|-- index.ts // データベース接続とサーバー起動
`-- router.ts // URLによるルーティング規則
###データベース用意
まずはコンソールに戻り、SQLite3クライアント(インストールしておいてください)を使ってデータベースを作成します。ここではid、名前、年齢だけの超単純なテーブルを作ります。ExpressTutorial
ディレクトリで、以下のように入力してデータベースを作成し、sqlite3コンソールに入ります。
$ sqlite3 src/db/database.sqlite3
sqlite3 >
そして以下のSQLコマンドでテーブルを作ります。
sqlite3 > create table User(id integer primary key, name text, age integer);
sqlite3 > .table
User
.table
と打ち込んでUserがでればテーブルができています。データベースの用意ができましたのでExpressサーバーを書いていきましょう。
###実装
controllers.tsから書きます。controllersではデータベースとの情報のやりとりを行います。読み込み(readAllUsers)、追加(insertSingleUser)、削除(deleteSingleUser)を書きます。sqlite3ライブラリは非同期処理をするので、async function
として定義します。コード末尾export default
でこれらの関数を外に出します。
import express from 'express';
import { db } from './index';
async function root(req: express.Request, res: express.Response) {
res.status(200).send('Connection Success');
}
async function readAllUsers(req: express.Request, res: express.Response) {
db.all(
"select * from User",
(err, rows) => {
if (err) {
sendError(res, err);
} else {
res.status(200).json(rows);
}
},
);
}
async function insertSingleUser(req: express.Request, res: express.Response) {
const data = req.body
const stmt = db.prepare("insert into User(name, age) values(?, ?)");
stmt.run([data.name, data.age],
function (this, err) {
if (err) {
sendError(res, err);
} else {
res.status(200).json({
id: this.lastID,
name: data.name,
age: data.age,
});
}
});
}
async function deleteSingleUser(req: express.Request, res: express.Response) {
db.run("delete from User where id = ?",
[req.params.id],
(err) => {
if (err) {
sendError(res, err);
} else {
res.status(200).send();
}
});
}
function sendError(res: express.Response, err: Error) {
res.status(401).json({
status: "error",
message: err.message
});
}
export default {
root,
readAllUsers,
insertSingleUser,
deleteSingleUser,
};
次にrouter.tsを書きます
router.tsではURLによるルーティングを実現します。controllers.tsの関数たちと、URLを紐づける役割を果たします。httpメソッド名の第一引数にroot(http://localhost:4001 )からのルートを代入し、第二引数に先程のcontrollers.tsで定義した関数を代入します。最後にexport default
で外に出します。
import express from 'express';
import controller from './controllers';
const router = express.Router();
router.get('/', controller.root);
router.get('/user/all', controller.readAllUsers);
router.post('/user/insert', controller.insertSingleUser);
router.delete('/user/delete/id/:id', controller.deleteSingleUser);
export default router;
index.tsはサーバーサイドのエントリーポイントであり、データベースの接続とExpressサーバーの起動を処理します。
import express from 'express';
import path from 'path';
import sqlite3 from 'sqlite3';
import router from './router';
import cors from 'cors';
const app = express();
const dirname = path.resolve();
export const db = new sqlite3.Database(
path.join(dirname, 'src', 'db', 'database.sqlite3'),
(err) => {
if (err) {
console.log(err.message);
}
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(router);
app.listen(4001, () => {
console.log('The server is listening on port 4001');
});
ここまでできたらyarn dev
で起動し、各URLにHTTPリクエストを送ってみて、データベースを読み込み/書き換えできるかテストしてみましょう。ちなみに筆者はよくHTTPリクエストのテストに、こちらのVSコードで試せるHTTPクライアントのプラグインを使います。
https://marketplace.visualstudio.com/items?itemName=Aaron00101010.http-client
サーバーサイドは完了です
#フロントエンド
お待ちかねのReactです。
###プロジェクトの作成
プロジェクトを新規作成して中に入ります。
$ npx create-react-app ReduxTutorial
$ cd ReduxTutorial
必要なライブラリを入れます。
多いので以下のdependenciesをご自分のpackage.jsonに貼り付け保存して、
{
"name": "redux-sandbox",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@material-ui/core": "^4.12.3",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-redux": "^7.1.18",
"axios": "^0.22.0",
"path": "^0.12.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.5",
"react-scripts": "4.0.3",
"ts-node": "^10.2.1",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
}
}
yarn installします。
$ yarn install
うまくいかなければyarn.lockを削除してyarn install
してください。
インストールが終わったら、yarn start
でブラウザが立ち上がりますので動作しているか確認してください。
次にコーディングしていきます。
###目標の構成
ディレクトリ構成は以下のように作っていきます。
src
|-- App.tsx
|-- entities
| `-- user.ts
|-- features
| |-- counter
| | |-- Counter.tsx
| | `-- counter_slice.ts
| `-- users
| |-- Users.tsx
| |-- users_async_thunk.ts
| `-- users_slice.ts
`-- store
|-- hooks.ts
`-- store.ts
###Entity実装
entities/user.tsを作成し、User
の構造体を作成します
export interface User {
id?: number,
name: String,
age?: number,
}
###Store実装
ReduxのStoreを作成します。
store/store.tsを作成し、
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counter_slice';
import usersReducer from '../features/users/users_slice';
export const store = configureStore({
reducer: {
counter: counterReducer,
users: usersReducer,
},
})
// storeの構造から`RootState`と`AppDispatch`の型が推論されます
export type RootState = ReturnType<typeof store.getState>
// 推論された`RootState`型: {counter: CounterState, users: UsersState}
export type AppDispatch = typeof store.dispatch
と書きます
storeはフロントエンド全体のデータ保持するインスタンスとなります。
次にstore/hooks.tsを作成し、
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
と書きます
useAppDispatch
がstateを変化させるための関数をコールするdispatcherです。
###Features(Counter)実装
同期処理のstate管理を目的として、
Featureの一つであるCounterを作ります。
counter/counter_slice.tsを作成し、
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// Counter Featureで使うstateの型(必要な変数の型)を定義
interface CounterState {
value: number
}
// 上で定義した型に従って初期値を定義
const initialState: CounterState = {
value: 0
}
const counterSlice = createSlice({
name: 'counter',
// `createSlice`は`initialState`からstateの型を推論します
initialState, // 初期値
reducers: {
// +1ボタン押した時のアクション
increment: (state) => {
state.value += 1;
},
// -1ボタン押した時のアクション
decrement: (state) => {
state.value -= 1;
},
// +(任意の数)ボタン押した時のアクション
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
}
});
// UIから参照できるようにexport
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// store.tsで参照しているcounterSlice.reducerはここでexport
export default counterSlice.reducer;
と書きます。
sliceのイメージとしては、storeからCounter部分だけを切り出してstate変化させる関数を定義すると言う感じです。
そしてCounter FeatureのUIコンポーネントを書きます。
import React, { useState } from 'react';
import { makeStyles, Button } from '@material-ui/core';
import { useAppSelector, useAppDispatch } from '../../store/hooks';
import { decrement, increment, incrementByAmount } from './counter_slice';
const useStyles = makeStyles((theme) => ({
buttonsParent: {
display: 'flex',
alignItems: 'center'
},
button: {
margin: 20,
}
}))
export function Counter() {
// `state`は既に`RootState`型として推論されています
const count = useAppSelector((state) => state.counter.value);
// dispatcherを参照します
const dispatch = useAppDispatch();
const [incrementAmount, setIncrementAmount] = useState('5');
const classes = useStyles();
// dispatchのコールバックとしてsliceで定義したアクションを渡せばstate変化します
return (
<div>
<div className={classes.buttonsParent}>
<Button className={classes.button} color="primary" variant="contained" onClick={() => dispatch(increment())}>Increment</Button>
<Button className={classes.button} color="primary" variant="contained" onClick={() => dispatch(decrement())}>Decrement</Button>
</div>
<div className={classes.buttonsParent}>
<Button className={classes.button} color="primary" variant="contained" onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}>Increment by</Button>
<input type="text" value={incrementAmount} onChange={(e) => setIncrementAmount(e.target.value)} />
</div>
<div>
<p>{count}</p>
</div>
</div>
);
}
最後にApp.tsxとindex.tsを以下のように編集してCounter Featureは終了です。
import './App.css';
// コンポーネントの参照
import { Counter } from './features/counter/Counter';
function App() {
return (
<div className="App">
<header className="App-header">
<Counter /> {/* 追加 */}
</header>
</div>
);
}
export default App;
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { store } from './store/store';
ReactDOM.render(
<React.StrictMode>
<Provider store={store}> {/* Providerで挟む。必ずstoreを渡す */}
<App />
</Provider>
</React.StrictMode>
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
次に非同期的な処理をやっていきます
###Features(Users)実装
非同期処理のstate管理を目的として、Featureの一つであるUsersを作ります。Reduxの非同期通信では、sliceの外で非同期関数を定義し、sliceと繋げます。そこでsrc/features/users/users_async_thunk.tsを作成し、非同期処理をする関数を定義していきます。
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { User } from '../../entities/user';
const url = 'http://localhost:4001';
// DBに登録されているユーザーをfetchするアクション
export const fetchAllUsers = createAsyncThunk('user/all', async () => { // ここの'user/all'はアクションのタグみたいなもの
const res = await axios.get<Array<User>>(`${url}/user/all`); // http通信(非同期)
return res.data;
});
// DBにユーザーを登録する
export const insertUser = createAsyncThunk('user/insert', async (data: User) => {
const res = await axios.post<User>(`${url}/user/insert`, data); // http通信(非同期)
let lastUser: User = {
id: res.data.id,
name: res.data.name,
age: res.data.age,
};
return lastUser;
});
// DBからユーザーを削除する
export const deleteUser = createAsyncThunk('user/delete', async (data: User) => {
await axios.delete<User>(`${url}/user/delete/id/${data.id}`); // http通信(非同期)
});
のように書きます
次にsrc/features/users/users_slice.tsを作成し、
import { createSlice } from '@reduxjs/toolkit';
import { User } from '../../entities/user';
import { deleteUser, fetchAllUsers, insertUser } from './users_async_thunk';
// 基本はCounterの時と同じ
interface UsersState {
users: Array<User>,
inputName: string,
inputAge: number | undefined,
flagToFetchAllUsers: boolean,
}
const initialState: UsersState = {
users: [],
inputName: '',
inputAge: undefined,
flagToFetchAllUsers: false,
}
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
setInputName: (state, action) => {
state.inputName = action.payload;
},
setInputAge: (state, action) => {
state.inputAge = action.payload;
},
},
// ここにusers_async_thunk.tsで定義した非同期処理が終了した後のstate変化を示します
extraReducers: (builder) => {
// `fetchAllUsers.fulfilled`で、非同期処理が完了したタイミングを表します
builder.addCase(fetchAllUsers.fulfilled, (state, action) => {
state.users = action.payload;
});
builder.addCase(insertUser.fulfilled, (state, action) => {
state.users.push(action.payload);
state.flagToFetchAllUsers = !state.flagToFetchAllUsers;
});
builder.addCase(deleteUser.fulfilled, (state) => {
state.flagToFetchAllUsers = !state.flagToFetchAllUsers;
});
}
});
export default usersSlice.reducer;
export const { setInputName, setInputAge } = usersSlice.actions;
と書きます。
最後にUIコンポーネントとApp.tsxへの埋め込み。
import React from 'react';
import { makeStyles, Button, Table, TableHead, TableRow, TableBody, TableCell } from '@material-ui/core';
import { useAppSelector, useAppDispatch } from '../../store/hooks';
import { fetchAllUsers, insertUser, deleteUser } from './users_async_thunk';
import { setInputAge, setInputName } from './users_slice';
const useStyles = makeStyles((theme) => ({
buttonsParent: {
display: 'flex',
alignItems: 'center'
},
button: {
margin: 20,
}
}))
export function Users() {
const users = useAppSelector((state) => state.users.users);
const inputName = useAppSelector((state) => state.users.inputName);
const inputAge = useAppSelector((state) => state.users.inputAge);
const flagToFetchAllUsers = useAppSelector((state) => state.users.flagToFetchAllUsers);
const dispatch = useAppDispatch();
const classes = useStyles();
// `flagToFetchAllUsers`の変化を観測して変化するときに`fetchAllUsers`アクションを発動させている
React.useEffect(() => {
dispatch(fetchAllUsers());
}, [flagToFetchAllUsers]);
return (
<div>
<div className={classes.buttonsParent}>
<div>
<input type="text" value={inputName} onChange={(e) => dispatch(setInputName(e.target.value))} />
<input type="number" value={inputAge} onChange={(e) => dispatch(setInputAge(Number(e.target.value)))} />
</div>
<Button className={classes.button} variant="contained" color="primary" onClick={() => dispatch(insertUser({ id: undefined, name: inputName, age: inputAge }))}> Insert </Button>
</div>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Age</TableCell>
<TableCell>Command</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow >
<TableCell>{user.id}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{user.age}</TableCell>
<TableCell>
<Button color="primary" onClick={() => dispatch(deleteUser({ id: user.id, name: user.name, age: user.age }))}> Delete</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
import './App.css';
import { Counter } from './features/counter/Counter';
import { Users } from './features/users/Users';
function App() {
return (
<div className="App">
<header className="App-header">
<Counter />
<Users /> {/* 追加 */}
</header>
</div>
);
}
export default App;
#完成したアプリの確認
あとはサーバーサイド、フロントエンドでそれぞれyarn dev
とyarn start
で起動して思い通りに動けば成功です
$ cd ExpressTutorial
$ yarn dev
新しいターミナルウィンドウを開いて、
$ cd ReduxTutorial
$ yarn start
以上で終わりです。
アドバイス、コードレビューなど気がついたことがございましたらコメントよろしくお願いいたします。