LoginSignup
1
4

More than 1 year has passed since last update.

TypeScriptでReact + Redux + Expressのベストプラクティス

Last updated at Posted at 2021-10-16

こんにちは、Morishing362と申します。
UI/UXエンジニアをしております。普段はTypeScript/Flutter/Rust/C++などを触っています。仕事以外でツールを作る機会があり、Reactでやってみたく、その際にベストプラクティスを作ってみたので晒します。ベースは公式チュートリアルに則って少しアレンジしました。

各フレームワークの役割

  • React: フロントエンドフレームワーク
  • Redux: Reactの状態管理
  • Express: HTTPサーバーフレームワーク

目標のアプリ

左側に比較的簡単な状態管理のカウンター機能、右側にデータベースを使った非同期処理を伴うユーザー追加機能を持つ、なんの意味もないアプリです。

Screen Shot 2021-10-16 at 16.47.48.png

環境

  • Yarn: 1.22.5
  • node: 14系
  • SQLite3クライアント: 3.31.1

npmやYarnのインストール手順は世の中にたくさんあるので省きます。

忙しい人向け

色々と忙しいと思いますのでソースコード置いておきます。

サーバーサイド

プロジェクトの作成

サーバーサイドから行きます!まずは作業ディレクトリ作成とその初期化から。

$ 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を以下のように追加(お好みで)。

package.json
{
  ...dependenciesとか...
  ...

  "scripts": {
    "dev": "ts-node ./src/index.ts"
  }
}

サーバーサイド接続テスト

Expressを使ってテストします。src/index.tsに以下のように書きます。

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でこれらの関数を外に出します。

src/controllers.ts
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で外に出します。

src/router.ts
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サーバーの起動を処理します。

src/index.ts
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に貼り付け保存して、

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の構造体を作成します

src/entities/user.ts
export interface User {
    id?: number,
    name: String,
    age?: number,
}

Store実装

ReduxのStoreを作成します。
store/store.tsを作成し、

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を作成し、

src/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を作成し、

src/features/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コンポーネントを書きます。

src/features/counter/Counter.tsx
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は終了です。

App.tsx
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;
index.ts
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を作成し、非同期処理をする関数を定義していきます。

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を作成し、

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への埋め込み。

src/features/users/User.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>
    );
}
App.tsx
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 devyarn startで起動して思い通りに動けば成功です

$ cd ExpressTutorial
$ yarn dev

新しいターミナルウィンドウを開いて、

$ cd ReduxTutorial
$ yarn start

以上で終わりです。

アドバイス、コードレビューなど気がついたことがございましたらコメントよろしくお願いいたします。

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4