LoginSignup
13
9

More than 3 years have passed since last update.

React + Redux + Redux-Saga + Express + Mongo DB + TypeScriptでCRUDなWebアプリケーションを作成してHerokuにデプロイしてみる

Last updated at Posted at 2020-06-02

概要

名前と年齢を入力し、保存・編集・削除ができるCRUDなWebアプリケーションを作成してHerokuにデプロイした備忘録になります(使ってみたい技術スタックが多かったため、長文な記事になってしまいました)。

crud.jpg

Webアプリケーションを作成する上で以下の記事を参考にさせていただきました。TypeScript等を導入する差異がありますので以下の記事の流れに沿って改めて説明させていただきます。

前提

node + npmがインストールされており、問題なく使用できる環境であることを前提条件とさせて下さい。
インストールされていない場合は、以下の記事を参考にしてみて下さい。

本記事では、yarnを使用しています。
yarn -vのコマンドを叩いてバージョンが表示されない方は以下のコマンドでインストールして下さい。

$ npm i -g yarn

npmで動作させたい方は適宜、package.jsonの修正をお願いします。

コード

Githubのリポジトリは以下になります。

サーバーサイドの環境構築

サーバーサイドの環境構築では以下の内容を行います。

  • MongoDBのインストール
  • jqのインストール
  • モジュールのインストール
  • Webpackでビルド環境の構築
  • ホットリロード環境の構築

MongDBのインストール

HomebrewでMongoDBをインストールします。

$ brew tap mongodb/brewbrew tap
$ brew install mongodb-community

以下のコマンドを叩いてバージョンが表示されれば成功です。

$ mongod --version

jqのインストール

curlでAPIのリクエストで返却される値を見やすい形に整形してくれます。

$ brew install jq

以下のコマンドを叩いてバージョンが表示されれば成功です。

$ jq --version

モジュールのインストール

サーバーサイドでは、Express、MongoDB、TypeScriptを使用し、Webpackでビルドを行います。

$ mkdir simple-crud
$ cd simple-crud

$ npm i -y
$ yarn add express mongoose nodemon

/** TypeScript用 */
$ yarn add -D @types/express @types/mongoose @types/node typescript

/** Webpack用 */
$ yarn add -D dotenv-webpack ts-loader tslint tslint-config-airbnb tslint-loader webpack webpack-cli webpack-node-externals

Webpackでビルド環境の構築

Webpackでは、TypeScriptをAirBnbのLintルールに従ってビルドします。
各自、使用したいルールがあれば、そちらを使用して下さい。

tsconfig.jsonの作成

TypeScriptのコンパイラーオプションを設定します。
現状だとclient/配下も範囲に含まれるのでエラーが発生します。
エラーを回避するために"include" : ["src/**/*]"を記述し、範囲を選択します。

$ npx tsc --init
tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "ES2015",
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

tslint.jsonの作成

AirBnbのLintルールを適用させます。
tsconfig.jsonの作成と同様の理由のため、"exclude": ["./client/src/**/*"]を記述します。

tslint.json
{
  "extends": "tslint-config-airbnb",
  "linterOptions": {
    "exclude": ["./client/src/**/*"]
  }
}

webpack.config.jsの作成

WebpackでTypeScriptのトランスパイル、TSLintによるチェック、dotenvの読み込みを行います。
dotenvは、.envファイルを読み込む時に使用します。
src/server.tsをターゲットにビルドを行い、dist/にビルド結果が吐き出されます。

webpack.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const dotenv = require('dotenv-webpack');

module.exports = {
  entry: './src/server.ts',
  target: 'node', // Module not found: Error: Can't resolve 'fs' 対策
  externals: [nodeExternals()],
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        enforce: 'pre',
        loader: 'tslint-loader',
        test: /\.ts$/,
        exclude: [/node_modules/],
        options: {
          emitErrors: true,
        },
      },
      {
        loader: 'ts-loader',
        test: /\.ts$/,
        exclude: [/node_modules/],
        options: {
          configFile: 'tsconfig.json',
        },
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [new dotenv({ systemvars: true })],
};

フロントエンドの環境構築

フロントエンドのの環境構築では以下の内容を行っていきます。

  • create-react-appを使用したReactプロジェクトの作成
  • 不要なファイル・フォルダの削除
  • モジュールのインストール

create-react-appを使用したプロジェクトの作成

creact-react-appコマンドで簡単に環境を構築することができます。
今回は、TypeScriptを使用するのでコマンドの最後に--typescriptを付与します。

/** グローバルにコマンドをインストール */
$ npm i -g create-react-app

/** clientという名前のプロジェクトをTypeScriptで作成 */
$ create-react-app client --typescript

不要なファイル・フォルダの削除

create-react-appのバージョンで異なるかもしれませんが、プロジェクト作成時に今回は使用しない不要なファイル、フォルダが存在するので削除します。

rm .gitignore README.md
cd src
rm App.test.tsx logo.svg *.css serviceWorker.ts setupTests.ts

モジュールのインストール

AxiosとRedux-Sagaによる非同期処理、Redux-loggerによるロガーの出力を行います。
今回、Scssでスタイリングしていますが必須ではないので必要であれば、インストールして下さい。

$ cd client
$ yarn add axios node-sass

/** Redux用 */
$ yarn add redux react-redux redux-saga redux-logger

/** TypeScript用 */
$ yarn add @types/react-redux @types/redux-logger typescript-fsa typescript-fsa-reducers

package.jsonの修正

npm scriptを使用し、コマンドから実行できるようにルートのpackage.jsonを修正します。
Herokuのデプロイ時に必要になる、enginesheroku-postbuildコマンドも一緒に追記してあります。
基本的には、yarn start:devで開発を行い、Herokuにデプロイする時は、yarn build:prodでビルドを行います。

/** localhost:3000 */
yarn start:dev

/** localhost:3001 */
yarn start:prod
package.json
{
  "name": "simple-crud",
  "engines": {
    "yarn": "1.x"
  },
  "main": "dist/server.js",
  "scripts": {
    "start:dev": "yarn watch:dev & yarn watch:react",
    "start:prod": "node dist/server.js",
    "build:dev": "webpack --mode development && yarn build:react",
    "build:prod": "webpack --mode production && yarn build:react",
    "build:react": "cd client && yarn build",
    "watch:dev": "webpack -w --mode development & nodemon dist/server.js",
    "watch:react": "cd client && yarn start",
    "heroku-postbuild": "webpack --mode production"
  },
  "dependencies": {
    "express": "^4.17.1",
    "mongoose": "^5.9.13"
  },
  "devDependencies": {
    "@types/express": "^4.17.6",
    "@types/mongoose": "^5.7.19",
    "@types/node": "^13.13.5",
    "dotenv-webpack": "^1.8.0",
    "nodemon": "^2.0.4",
    "ts-loader": "^7.0.4",
    "tslint": "^6.1.2",
    "tslint-config-airbnb": "^5.11.2",
    "tslint-loader": "^3.5.4",
    "typescript": "^3.8.3",
    "webpack": "4.42.0",
    "webpack-cli": "^3.3.11",
    "webpack-node-externals": "^1.7.2"
  }
}

ディレクトリ構造

現状で以下のディレクトリ構造であれば問題ありません。
サーバーサイドでは、src、フロントでは、client/srcに記述していきます。

simple-crud
├── README.md
├── client
│   ├── README.md
│   ├── package.json
│   ├── src
│   │   ├── components
│   │   │   └── App.tsx
│   │   └── index.tsx
│   ├── tsconfig.json
│   └── yarn.lock
├── package.json
├── src
├── tsconfig.json
├── tslint.json
├── webpack.config.js
└── yarn.lock

サーバーサイドのアプリケーション作成

サーバーサイドのアプリケーション作成では以下の内容を行います。

  • mongooseでスキーマの定義
  • ExpressでRest APIの作成
  • MongoDBにリクエストの送信

mongooseでスキーマの定義

名前と年齢を持ったCharacterというスキーマを定義します。
どちらもString型で必須項目にします。

src/character.ts
import mongoose from 'mongoose';

const characterSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
  },
  age: {
    type: String,
    required: true,
  },
});

const character = mongoose.model('Character', characterSchema);

export default character;

ExpressでRest APIの作成

MongoDBに接続し、APIサーバーを立てる準備をします。

src/server.ts
import character from './character';
import express from 'express';
import bodyParser from 'body-parser';
import mongoose from 'mongoose';

const app = express();
const port = process.env.PORT || 3001;
const dbUrl = process.env.MONGODB_URI || 'mongodb://localhost/crud';

app.use(express.static('client/build'));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

mongoose.connect(
  dbUrl,
  { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false },
  (dbError) => {
    if (dbError) {
      console.log(dbError);
      throw new Error(`${dbError}`);
    } else {
      console.log('db connected');
    }

    app.listen(port, () => {
      console.log(`listening on port ${port}`);
    });
  },
);

以下のコマンドでMongoDBを起動し、サーバーを立てます。

/** MongoDBを起動する
$ brew services start mongodb-community

/** localhost:3001でサーバーを立てる */
$ yarn watch:dev

/** MongoDBを終了する
$ brew services stop mongodb-community

ターミナルに以下の内容が表示されれば成功です。

db connected
listening on port 3001

POST

DBに値を送信して保存できるようにPOSTから作成します。

src/server.ts

/** 中略 */

mongoose.connect(
  dbUrl,
  { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false },
  (dbError) => {

    /** 入力された名前と年齢をPOSTする */
+   app.post('/api/characters', (request, response) => {
+     const { name, age } = request.body;
+     new character({
+       name,
+       age,
+     }).save((error) => {
+       if (error) {
+         response.send(500).send();
+       } else {
+         character.find({}, (findError, characterArray) => {
+           if (findError) {
+             response.status(500).send();
+          } else {
+             response.status(200).send(characterArray);
+           }
+         });
+       }
+     });
+   });

    app.listen(port, () => {
      console.log(`listening on port ${port}`);
    });
  },
);

実際の動作するか以下のコマンドで確認してみましょう。
名前が「hoge」、年齢が「34」 のデータです。

curl -v -Ss -X POST -d 'name=hoge&age=34' http://localhost:3001/api/characters | jq

以下のようなJSON形式で返却されれば成功です。

[
  {
    "_id": "5ecd381cbdce9b04b809441f", // ユニークID
    "name": "hoge",
    "age": "34",
    "__v": 0
  }
]

GET

POSTで値の保存が出来たのでDBに保存されている値を取得できるようにGETを作成します。

src/server.ts

/** 中略 */

mongoose.connect(
  dbUrl,
  { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false },
  (dbError) => {

    /** 中略 */

    /** DBに保存された名前と年齢をGETする */
+   app.get('/api/characters', (_, response) => {
+     character.find({}, (error, characterArray) => {
+       if (error) {
+         response.status(500).send();
+       } else {
+         response.status(200).send(characterArray);
+       }
+     });
+   });

    app.listen(port, () => {
      console.log(`listening on port ${port}`);
    });
  },
);

実際の動作するか以下のコマンドで確認してみましょう。

curl -v -Ss http://localhost:3001/api/characters | jq

以下のようなJSON形式で返却されれば成功です。

[
  {
    "_id": "5ecd381cbdce9b04b809441f", // ユニークID
    "name": "hoge",
    "age": "34",
    "__v": 0
  }
]

PUT

DBに保存されている値を更新できるようにPUTを作成します。

src/server.ts

/** 中略 */

mongoose.connect(
  dbUrl,
  { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false },
  (dbError) => {

    /** 中略 */

    /** 指定されたユニークIDの名前と年齢をPUTする */
+   app.put('/api/characters', (request, response) => {
+     const { id, name, age } = request.body;
+     /** $setでキーを選択してアップデートする */
+     character.findByIdAndUpdate(id, { $set: { name, age } }, (error) => {
+       if (error) {
+         response.status(500).send();
+       } else {
+         character.find({}, (findError, characterArray) => {
+           if (findError) {
+             response.status(500).send();
+           } else {
+             response.status(200).send(characterArray);
+           }
+         });
+       }
+     });
+   });

    app.listen(port, () => {
      console.log(`listening on port ${port}`);
    });
  },
);

実際の動作するか以下のコマンドで確認してみましょう。
今回は先程、取得したユニークIDである5ecd381cbdce9b04b809441fを更新します。

curl -v -Ss -X PUT -d 'id=5ecd381cbdce9b04b809441f&name=fuga&age=43' http://localhost:3001/api/characters | jq

以下のようなJSON形式で返却されれば成功です。

[
  {
    "_id": "5ed1dad209d18805b763e610",
    "name": "fuga",
    "age": "43",
    "__v": 0
  }
]

DELETE

DBに保存されている値を削除できるようにDELETEを作成します。

src/server.ts

/** 中略 */

mongoose.connect(
  dbUrl,
  { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false },
  (dbError) => {

    /** 中略 */

    /** 指定されたユニークIDの名前と年齢を削除する */
+   app.delete('/api/characters', (request, response) => {
+     const { id } = request.body;
+     character.findByIdAndRemove(id, (error) => {
+       if (error) {
+         response.status(500).send();
+       } else {
+         character.find({}, (findError, characterArray) => {
+           if (findError) {
+             response.status(500).send();
+           } else {
+             response.status(200).send(characterArray);
+           }
+         });
+       }
+     });
+   });

    app.listen(port, () => {
      console.log(`listening on port ${port}`);
    });
  },
);

実際の動作するか以下のコマンドで確認してみましょう。
今回は先程、取得したユニークIDである5ecd381cbdce9b04b809441fを更新します。

curl -v -Ss -X DELETE -d 'id=5ed1dad209d18805b763e610' http://localhost:3001/api/characters | jq

以下のようなJSON形式で返却されれば成功です。

[]

長くなりましたが、以上でサーバーサイドの実装は完了です。

フロントエンドのアプリケーション作成

フロントエンドのアプリケーション作成では以下の内容を行います。

  • Reduxの使用
  • コンポーネントの作成
  • コンポーネントの表示とReactとReduxを繋げる

Reduxの使用

Reduxアーキテクチャに沿って以下の内容を定義していきます。
また、Reduxと切り分けて非同期処理を行うRedux-Sagaの作成、見た目とロジックを分担するためのコンテナーの作成も一緒に行います。

  • Actionの作成
  • Reducerの作成
    • フォーム
    • キャラクター
  • Redux-Sagaで非同期処理
    • POST
    • GET
    • PUT
    • DELETE
  • Storeの作成
  • Containerの作成
    • フォーム
    • キャラクター

Actionの作成

Actionを作成します。
TypeScriptで実装するため、typescript-fsaというライブラリーを使用します。
import { CharactersState } from './reducers/charactersReducer'でエラーが発生しますが、後からReducerで定義し、エラーを解消します。

client/redux/action.ts
import actionCreatorFactory from 'typescript-fsa';
import { CharactersState } from './reducers/charactersReducer';

const actionCreator = actionCreatorFactory();

export const formActions = {
  changeName: actionCreator<string>('CHANGE_NAME'),
  changeAge: actionCreator<string>('CHANGE_AGE'),
  initializeForm: actionCreator<void>('INITIALIZE_FORM'),
  postForm: actionCreator.async<{}, {}>('POST_FORM'),
};

export const characterActions = {
  editName: actionCreator<string>('EDIT_NAME'),
  editAge: actionCreator<string>('EDIT_AGE'),
  getCharacters: actionCreator.async<{}, CharactersState['characterArray']>(
    'GET_CHARACTERS',
  ),
  updateCharacters: actionCreator.async<{}, CharactersState['characterArray']>(
    'UPDATE_CHARACTERS',
  ),
  deleteCharacters: actionCreator.async<{}, CharactersState['characterArray']>(
    'DELETE_CHARACTERS',
  ),
};

Reducerの作成

Reducerを作成します。
TypeScriptで実装するため、typescript-fsa-reducersというライブラリーを使用します。

フォーム

フォームでは、送信に必要な値であるnameageのインターフェースを定義し、ステートとして持ちます。
changeNameがディスパッチされるとnameを返し、chagenAgeがディスパッチされるとageを返します。
postFormがディスパッチされると非同期処理が走り、成功するとステートが送信されます。

client/src/redux/reducers/formReducer.ts
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { formActions } from '../actions';

export interface FormState {
  name: string;
  age: string;
}

const initialState: FormState = {
  name: '',
  age: '',
};

export const formReducer = reducerWithInitialState(initialState)
  .case(formActions.changeName, (state, name) => {
    return {
      ...state,
      name,
    };
  })
  .case(formActions.changeAge, (state, age) => {
    return {
      ...state,
      age,
    };
  })
  .case(formActions.initializeForm, (state) => {
    return {
      ...state,
      name: '',
      age: '',
    };
  })
  .case(formActions.postForm.started, (state) => {
    return {
      ...state,
    };
  })
  .case(formActions.postForm.done, (state) => {
    return {
      ...state,
    };
  })
  .case(formActions.postForm.failed, (state) => {
    return {
      ...state,
    };
  });
キャラクター

キャラクターでは、名前と年齢を編集する時に必要な値であるnameage、DBに保存されているjsonのオブジェクトをインターフェースを定義し、ステートとして持ちます。
changeNamechagenAgeの挙動はフォームと同じです。
getCharactersがディスパッチされるとpayloadからDBに保存されている値を取得し、characterArrayに代入します。

client/src/redux/reducers/charactersReducer.ts
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { characterActions } from '../actions';

export interface CharactersState {
  name: string;
  age: string;
  isFetching: boolean;
  characterArray: {
    _id: string;
    name: string;
    age: string;
    _v: number;
  }[];
}

const initialState: CharactersState = {
  name: '',
  age: '',
  isFetching: false,
  characterArray: [],
};

export const characterReducer = reducerWithInitialState(initialState)
  .case(characterActions.editName, (state, name) => {
    return {
      ...state,
      name,
    };
  })
  .case(characterActions.editAge, (state, age) => {
    return {
      ...state,
      age,
    };
  })
  .case(characterActions.getCharacters.started, (state) => {
    return {
      ...state,
      isFetching: true,
    };
  })
  .case(characterActions.getCharacters.done, (state, payload) => {
    return {
      ...state,
      isFetching: false,
      characterArray: payload.result,
    };
  })
  .case(characterActions.getCharacters.failed, (state) => {
    return {
      ...state,
      isFetching: false,
    };
  })
  .case(characterActions.updateCharacters.started, (state) => {
    return {
      ...state,
    };
  })
  .case(characterActions.updateCharacters.done, (state, payload) => {
    return {
      ...state,
      characterArray: payload.result,
    };
  })
  .case(characterActions.updateCharacters.failed, (state) => {
    return {
      ...state,
    };
  })
  .case(characterActions.deleteCharacters.started, (state) => {
    return {
      ...state,
    };
  })
  .case(characterActions.deleteCharacters.done, (state, payload) => {
    return {
      ...state,
      characterArray: payload.result,
    };
  })
  .case(characterActions.deleteCharacters.failed, (state) => {
    return {
      ...state,
    };
  });

先程のimport { CharactersState } from './reducers/charactersReducer'のエラーは解消されたと思います。

Redux-Sagaで非同期処理

Redux-Sagaでは、以下の内容を行います。

  • POST
  • GET
  • PUT
  • DELETE
  • 各SagaをまとめたrootSagaの作成

また、非同期処理にはAxiosというライブラリーを使用します。
Sagaでは、条件式で取得に成功した時と失敗し時で分岐し、yieldでputします。

POST

POSTでは、preventDefaultするためのeと必要な値であるnameageを引数として持ちます。

client/src/redux/sagas/postFormSaga.ts
import { call, put, takeLatest } from 'redux-saga/effects';
import axios from 'axios';

import { characterActions, formActions } from '../actions';

const postForm = (
  e: React.FormEvent<HTMLFormElement>,
  name: string,
  age: string,
) => {
  e.preventDefault();
  return axios
    .post('/api/characters', {
      name,
      age,
    })
    .then((response) => {
      const characterArray = response.data;
      return { characterArray, e, name, age };
    })
    .catch((error) => {
      return { error };
    });
};

function* runPostForm(action: {
  type: string;
  payload: {
    e: React.FormEvent<HTMLFormElement>;
    name: string;
    age: string;
  };
}) {
  const { characterArray, e, name, age, error } = yield call(
    postForm,
    action.payload.e,
    action.payload.name,
    action.payload.age,
  );

  if (characterArray && e && name && age) {
    yield put(
      formActions.postForm.done({
        params: {},
        result: characterArray,
      }),
    );
    yield put(
      characterActions.getCharacters.done({
        params: {},
        result: characterArray,
      }),
    );
    yield put(formActions.initializeForm());
  } else {
    yield put(
      formActions.postForm.failed({
        params: {},
        error: error,
      }),
    );
  }
}

export const watchPostForm = [
  takeLatest(formActions.postForm.started, runPostForm),
];
GET

GETでは、resuponse.datacharacterArrayの変数に代入し、返します。

client/src/redux/sagas/getCharactersSaga.ts
import { call, put, takeLatest } from 'redux-saga/effects';
import axios from 'axios';

import { characterActions } from '../actions';

const getCharacters = () => {
  return axios
    .get('/api/characters')
    .then((response) => {
      const characterArray = response.data;
      return { characterArray };
    })
    .catch((error) => {
      return { error };
    });
};

function* runGetCharacters() {
  const { characterArray, error } = yield call(getCharacters);

  if (characterArray) {
    yield put(
      characterActions.getCharacters.done({
        params: {},
        result: characterArray,
      }),
    );
  } else {
    yield put(
      characterActions.getCharacters.failed({ params: {}, error: error }),
    );
  }
}

export const watchGetCharacters = [
  takeLatest(characterActions.getCharacters.started, runGetCharacters),
];
PUT

PUTでは、更新対象のidと必要な値であるnameageを引数として持ちます。

client/src/redux/sagas/updateCharactersSaga.ts
import { call, put, takeLatest } from 'redux-saga/effects';
import axios from 'axios';

import { characterActions } from '../actions';

const updateCharacters = (id: string, name: string, age: number) => {
  return axios
    .put('/api/characters', {
      id,
      name,
      age,
    })
    .then((response) => {
      const characterArray = response.data;
      return { characterArray, id, name, age };
    })
    .catch((error) => {
      return { error };
    });
};

function* runUpdateCharacters(action: {
  type: string;
  payload: { id: string; name: string; age: number };
}) {
  const { characterArray, id, name, age, error } = yield call(
    updateCharacters,
    action.payload.id,
    action.payload.name,
    action.payload.age,
  );

  if (characterArray && id && name && age) {
    yield put(
      characterActions.updateCharacters.done({
        params: {},
        result: characterArray,
      }),
    );
  } else {
    yield put(
      characterActions.updateCharacters.failed({ params: {}, error: error }),
    );
  }
}

export const watchUpdateCharacters = [
  takeLatest(characterActions.updateCharacters.started, runUpdateCharacters),
];
DELETE

DELETEでは、削除対象のidを引数として持ちます。

client/src/redux/saga/deleteCharactersSaga.ts
import { call, put, takeLatest } from 'redux-saga/effects';
import axios from 'axios';

import { characterActions } from '../actions';

const deleteCharacters = (id: string) => {
  return axios({
    method: 'delete',
    url: '/api/characters',
    data: {
      id,
    },
  })
    .then((response) => {
      const characterArray = response.data;
      return {
        characterArray,
        id,
      };
    })
    .catch((error) => {
      return { error };
    });
};

function* runDeleteCharacters(action: {
  type: string;
  payload: { id: string };
}) {
  const { characterArray, id, error } = yield call(
    deleteCharacters,
    action.payload.id,
  );

  if (characterArray && id) {
    yield put(
      characterActions.deleteCharacters.done({
        params: {},
        result: characterArray,
      }),
    );
  } else {
    yield put(
      characterActions.deleteCharacters.failed({ params: {}, error: error }),
    );
  }
}

export const watchDeleteCharacters = [
  takeLatest(characterActions.deleteCharacters.started, runDeleteCharacters),
];
各SagaをまとめたrootSagaの作成

allを使用して、importした各SagaをrootSagaとしてまとめます。

client/src/redux/sagas/index.ts
import { all } from 'redux-saga/effects';
import { watchPostForm } from './postFormSaga';
import { watchGetCharacters } from './getCharactersSaga';
import { watchUpdateCharacters } from './updateCharactersSaga';
import { watchDeleteCharacters } from './deleteCharactersSaga';

export default function* rootSaga() {
  yield all([
    ...watchPostForm,
    ...watchGetCharacters,
    ...watchUpdateCharacters,
    ...watchDeleteCharacters,
  ]);
}

Store

Storeを作成します。
Storeでは、redux-loggerによるログ出力等のミドルウェアの処理を作成します。

client/src/redux/store.ts
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { formReducer, FormState } from './reducers/formReducer';
import {
  characterReducer,
  CharactersState,
} from './reducers/charactersReducer';
import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga';
import rootSaga from './sagas';

const sagaMiddleware = createSagaMiddleware();

export type AppState = {
  form: FormState;
  character: CharactersState;
};

const logger = createLogger({
  diff: true,
  collapsed: true,
});

const store = createStore(
  combineReducers<AppState>({
    form: formReducer,
    character: characterReducer,
  }),
  {},
  applyMiddleware(sagaMiddleware, logger),
);

sagaMiddleware.run(rootSaga);
export default store;

Container

Containerでは、見た目とロジックを分担するために作成します。
Actionの作成同様にtypescript-fsaのライブラリーを使用します。
AddFormCharacterListのコンポーネントでエラーが発生しますが、後からコンポーネントを作成し、エラーを解消します。

フォーム
client/src/redux/container/AddFormContainer.ts
import { Action } from 'typescript-fsa';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { AppState } from '../store';
import { formActions } from '../actions';
import AddForm from '../../components/AddForm';

export interface AddFormActions {
  changeName: (inputValue: string) => Action<string>;
  changeAge: (inputValue: string) => Action<string>;
  initializeForm: () => Action<void>;
  postForm: (
    e: React.FormEvent<HTMLFormElement>,
    name: string,
    age: string,
  ) => Action<{}>;
}

const mapStateToProps = (appState: AppState) => {
  return {
    ...appState.form,
  };
};

const mapDispatchToProps = (dispatch: Dispatch<Action<string | void | {}>>) => {
  return {
    changeName: (inputValue: string) =>
      dispatch(formActions.changeName(inputValue)),
    changeAge: (inputValue: string) =>
      dispatch(formActions.changeAge(inputValue)),
    initializeForm: () => dispatch(formActions.initializeForm()),
    postForm: (
      e: React.FormEvent<HTMLFormElement>,
      name: string,
      age: string,
    ) => dispatch(formActions.postForm.started({ params: {}, e, name, age })),
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(AddForm);
リスト
client/src/redux/container/CharacterListContainer.ts
import { Action } from 'typescript-fsa';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { AppState } from '../store';
import { characterActions } from '../actions';
import CharacterList from '../../components/CharacterList';

export interface CharacterListActions {
  editName: (name: string) => Action<string>;
  editAge: (age: string) => Action<string>;
  getCharacters: () => Action<{}>;
  updateCharacters: (id: string, name: string, age: string) => Action<{}>;
  deleteCharacters: (id: string) => Action<{}>;
}

const mapStateToProps = (appState: AppState) => {
  return {
    ...appState.character,
  };
};

const mapDispatchToProps = (dispatch: Dispatch<Action<{}>>) => {
  return {
    editName: (name: string) => dispatch(characterActions.editName(name)),
    editAge: (age: string) => dispatch(characterActions.editAge(age)),
    getCharacters: () =>
      dispatch(characterActions.getCharacters.started({ params: {} })),
    updateCharacters: (id: string, name: string, age: string) =>
      dispatch(
        characterActions.updateCharacters.started({
          params: {},
          id,
          name,
          age,
        }),
      ),
    deleteCharacters: (id: string) =>
      dispatch(characterActions.deleteCharacters.started({ params: {}, id })),
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(CharacterList);

コンポーネントの作成

名前と年齢を入力するフォームと表示するリストを作成します。
Containerで定義したインターフェースをインポートし、タイプとして定義します。

フォーム

フォームでは、onChangeを使用して名前と年齢の入力を検知し、onSubmitで入力された内容を送信します。

client/src/components/AddForm.tsx
import React from 'react';
import { FormState } from '../redux/reducers/formReducer';
import { AddFormActions } from '../redux/container/AddFormContainer';

type AddFormProps = FormState & AddFormActions;

const AddForm: React.FC<AddFormProps> = (props) => {
  const { name, age } = props;

  return (
    <div className="AddForm">
      <h2 className="AddForm__title">フォーム</h2>
      <form onSubmit={(e) => props.postForm(e, name, age)}>
        <input
          className="AddForm__input"
          placeholder="名前"
          value={name}
          onChange={(e) => props.changeName(e.target.value)}
        />
        <input
          className="AddForm__input"
          placeholder="年齢"
          value={age}
          onChange={(e) => props.changeAge(e.target.value)}
        />
        <button className="AddForm__submit" type="submit">
          送信
        </button>
      </form>
    </div>
  );
};

export default AddForm;

リスト

リストでは、useEffectで初期レンダー時にDBに保存されている値を表示します。
useCallbackとuseStateで選択された名前と年齢を編集します。

client/src/components/CharacterList.tsx
import React, { useCallback, useEffect, useState } from 'react';
import { CharactersState } from '../redux/reducers/charactersReducer';
import { CharacterListActions } from '../redux/container/CharacterListContainer';

import './CharacterList.scss';

type CharacterListProps = CharactersState & CharacterListActions;

const CharacterList: React.FC<CharacterListProps> = (props) => {
  const [edit, setEdit] = useState(
    new Array<boolean>(props.characterArray.length).fill(false),
  );

  useEffect(() => {
    props.getCharacters();
  }, []);

  const editHandler = useCallback(
    (index: number) => {
      const newArray = [...edit];
      newArray[index] = !newArray[index];
      setEdit(newArray);
    },
    [edit],
  );

  return (
    <div className="CharacterList">
      {props.isFetching ? (
        <h2 className="CharacterList__title">Now Loading...</h2>
      ) : (
        <div>
          <h2 className="CharacterList__title">リスト</h2>
          <ul className="CharacterList__list">
            {props.characterArray.map((character, index) => (
              <li key={character._id} className="CharacterList__listItem">
                {edit[index] ? (
                  <React.Fragment>
                    <input
                      className="CharacterList__edit"
                      defaultValue={character.name}
                      onChange={(e) => props.editName(e.target.value)}
                    />
                    <input
                      className="CharacterList__edit"
                      defaultValue={character.age}
                      onChange={(e) => props.editAge(e.target.value)}
                    />
                  </React.Fragment>
                ) : (
                  `${character.name} (${character.age})`
                )}
                <div className="CharacterList__listArea">
                  <button
                    className="CharacterList__listButton"
                    onClick={() => {
                      props.updateCharacters(
                        character._id,
                        props.name ? props.name : character.name,
                        props.age ? props.age : character.age,
                      );
                      editHandler(index);
                    }}
                  >
                    <i className="gg-edit-markup"></i>
                  </button>
                  <button
                    className="CharacterList__listButton"
                    onClick={() => props.deleteCharacters(character._id)}
                  >
                    <i className="gg-trash"></i>
                  </button>
                </div>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

export default CharacterList;

先程のAddFormCharacterListのコンポーネントで発生したエラーが解消されたと思います。

コンポーネントを表示する

フォームとリストのコンポーネントを作成したので表示します。
現在、コンテナーでコンポーネントをコネクトしてます。
そのため、コンポーネントではなく、コンテナーをインポートします。

client/src/components/App.tsx
import React from 'react';
import AddForm from '../redux/container/AddFormContainer';
import CharacterList from '../redux/container/CharacterListContainer';

import './App.scss';

const App: React.FC = () => {
  return (
    <div className="App">
      <AddForm />
      <CharacterList />
    </div>
  );
};

export default App;

コンポーネントの表示とReactとReduxを繋げる

react-reduxというライブラリーのProviderを使用して、Storeを受け取れるようにします。

client/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
+ import { Provider } from 'react-redux';
+ import Store from './redux/store';
import App from './components/App';

import './index.scss';

ReactDOM.render(
  <React.StrictMode>
+   <Provider store={Store}>
      <App />
+   </Provider>
  </React.StrictMode>,
  document.getElementById('root'),
);

長くなりましたが、以上でフロントエンドの実装は完了です。

Herokuにデプロイ

Herokuのアカウントが無い方は作成して下さい。

Heroku CLIをインストール

コマンドラインから操作するため、CLIをインストールします。

$ brew tap heroku/brew && brew install heroku

Herokuにログイン

以下のコマンドを叩いてブラウザに遷移するのでログインします。

$ heroku login

Heroku appを作成

Herokuでアプリを作成し、MongoLabのアドオンを追加をします。
アドオンの追加にはHerokuにクレジットカード登録が必要です。
2020年6月2日の現時点、今回、使用するアドオンは無料になります。

$ heroku create 任意のアプリ名
$ heroku addons:create mongolab

MONGODB_URIの確認

現状のMongoDBはローカルDBなのでHerokuのMongoDBに変更します。
以下のコマンドを叩いてMONGODB_URIを確認します。

$ heroku config

.envファイルの作成

MONGODB_URI.envファイルに設定し、読み込まれるようにします。

MONGODB_URI=mongodb://heroku...(各自で確認したMONGODB_URI)

Procfileの作成

Procfileは、Herokuにデプロイ時のコマンドを指定できます。
ビルドされたserver.jsをnodeで起動します。

web: node dist/server.js

デプロイ

Herokuにデプロイするためにデプロイ先のURLを追加します。

$ git remote add https://git.heroku.com/任意のアプリ名.git

以下のコマンドでビルドが走り、デプロイされます。

$ git push heroku master

以上でHerokuへのデプロイが完了です。

13
9
3

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
13
9