概要
名前と年齢を入力し、保存・編集・削除ができるCRUDなWebアプリケーションを作成してHerokuにデプロイした備忘録になります(使ってみたい技術スタックが多かったため、長文な記事になってしまいました)。
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
{
"compilerOptions": {
"target": "es5",
"module": "ES2015",
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
tslint.jsonの作成
AirBnbのLintルールを適用させます。
tsconfig.json
の作成と同様の理由のため、"exclude": ["./client/src/**/*"]
を記述します。
{
"extends": "tslint-config-airbnb",
"linterOptions": {
"exclude": ["./client/src/**/*"]
}
}
webpack.config.jsの作成
WebpackでTypeScriptのトランスパイル、TSLintによるチェック、dotenvの読み込みを行います。
dotenvは、.env
ファイルを読み込む時に使用します。
src/server.ts
をターゲットにビルドを行い、dist/
にビルド結果が吐き出されます。
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のデプロイ時に必要になる、engines
とheroku-postbuild
コマンドも一緒に追記してあります。
基本的には、yarn start:dev
で開発を行い、Herokuにデプロイする時は、yarn build:prod
でビルドを行います。
/** localhost:3000 */
yarn start:dev
/** localhost:3001 */
yarn start:prod
{
"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型で必須項目にします。
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サーバーを立てる準備をします。
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から作成します。
/** 中略 */
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を作成します。
/** 中略 */
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を作成します。
/** 中略 */
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を作成します。
/** 中略 */
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で定義し、エラーを解消します。
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
というライブラリーを使用します。
フォーム
フォームでは、送信に必要な値であるname
とage
のインターフェースを定義し、ステートとして持ちます。
changeName
がディスパッチされるとname
を返し、chagenAge
がディスパッチされるとage
を返します。
postForm
がディスパッチされると非同期処理が走り、成功するとステートが送信されます。
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,
};
});
キャラクター
キャラクターでは、名前と年齢を編集する時に必要な値であるname
とage
、DBに保存されているjsonのオブジェクトをインターフェースを定義し、ステートとして持ちます。
changeName
とchagenAge
の挙動はフォームと同じです。
getCharacters
がディスパッチされるとpayload
からDBに保存されている値を取得し、characterArray
に代入します。
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
と必要な値であるname
とage
を引数として持ちます。
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.data
をcharacterArray
の変数に代入し、返します。
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
と必要な値であるname
とage
を引数として持ちます。
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
を引数として持ちます。
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としてまとめます。
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によるログ出力等のミドルウェアの処理を作成します。
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
のライブラリーを使用します。
AddForm
とCharacterList
のコンポーネントでエラーが発生しますが、後からコンポーネントを作成し、エラーを解消します。
フォーム
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);
リスト
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
で入力された内容を送信します。
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で選択された名前と年齢を編集します。
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;
先程のAddForm
とCharacterList
のコンポーネントで発生したエラーが解消されたと思います。
コンポーネントを表示する
フォームとリストのコンポーネントを作成したので表示します。
現在、コンテナーでコンポーネントをコネクトしてます。
そのため、コンポーネントではなく、コンテナーをインポートします。
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
を受け取れるようにします。
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へのデプロイが完了です。