概要
React や Redux などのフロントエンド側に続き、MDN のチュートリアル Express web framework (Node.js/JavaScript) - Learn web development | MDN などを試してみたりして、Express や MongoDB / Mongoose などバックエンド側の勉強をやっていた。
フロントエンドとバックエンドのピースが揃ってきたので、そろそろフルスタックのアプリを作ってみたい。MDN のチュートリアルの題材はシンプルであるものの、依存関係 のある 4 つの Mongoose Model(テーブルに相当) と、それぞれの CRUD 処理(一覧表示を含む)のページ(各 5 ページ)、そしてトップページを合わせ、合計 ページ からなり、それなりのボリュームがある。練習台として丁度良さそうなので、React を適用してフルスタックの MERN アプリ にしてみた。
前提
バックエンド (Express & Mongoose) と フロントエンド (React) の連携と、フロントエンドの実装の話がメインで、個別の 要素技術については解説しません。それらの基本的な事柄を理解していることを、前提とさせて頂きます。
ちなみに発端となった MDN のチュートリアル Express web framework (Node.js/JavaScript) - Learn web development | MDN は、Express と Mongoose の入門編としてオススメです!
MDN Express Tutorial の概説
題材
- 図書館のカタログ
- 管理する情報
- 著者
- ジャンル
- 本: タイトル、著者、タイトル、サマリー、ISBN、ジャンル
- 本の現物: 本、貸し出し状態、貸し出し可能予定日など
- 機能 → 各情報の CRUD 処理
- 一覧表示
- 詳細表示
- 追加
- 編集
- 削除
- ユーザ認証・管理は考えない
画面のスクリーンショットを挿入
実装の方針
- サーバーサイドでページ生成が完結する MVC アーキテクチャ
- モデル → Mongoose
- Author(著者)
- Genre(ジャンル)
- Book(本): Author と Genre を参照 ← 依存関係
- BookInstance(本の現物): Book を参照 ← 依存関係
- コントローラ → Express
- 入力値のサニタイズ & 検証 → express-validator
- ビュー → Pug & Bootstrap
- 入力フォームを含む追加ページと編集ページは、一つのテンプレートファイルを共有
コードの一部(View)
コードの一部(コントローラ、更新処理)
MERN スタック化の方針
- バックエンド → Express & Mongoose
- REST API を提供
- フロントエンド → React
- Hooks & 関数コンポーネント
- React Router を使って SPA (Single-Page Application) にする
- バックエンドとの通信処理
- axios を利用
- 記事 React Hooks CRUD example with Axios and Web API - BezKoder を参考にして、通信処理の責務を負う DataService をモデル毎に作成
- バックエンドとフロントエンドの連携
- フロントエンド開発時は ホットリロード を利用したいので、フロントエンドの開発サーバを経由 してバックエンドへアクセスするように設定
全体のディレクトリ構成
本番環境では Express サーバがフロントエンドを提供する事を想定して、バックエンド / フロントエンド と入れ子の構成にした。
- /: バックエンド
- app.js: Express サーバのメイン
- controllers/ Express サーバのコントローラ
- models/ Mongoose モデル
- routes/ Express サーバのルータ
- react/ → フロントエンド
React のディレクトリについては、僕は自作のボイラープレートを利用したが、Create React App などを利用しても今回は問題ない。
バックエンドとフロントエンドの連携
ポイント
- concurrently
- webpack の devServer のプロキシ機能
まず、concurrently を利用して、バックエンドとフロントエンドの開発サーバを同時に起動できるようにする。
$ npm install concurrently --save-dev
/package.json(バックエンド側)
"scripts": {
"start": "node ./app.js",
"dev": "concurrently -n frontend,backend \"npm run frontend-dev\" \"npm run backend-dev\"",
"backend-dev": "nodemon ./app.js",
"frontend-dev": "npm run start --prefix react"
},
このように設定すると、次のコマンド一つでバックエンドとフロントエンドが開発モードで同時に起動する。
$ npm run dev
次に、フロントエンド開発時は ホットリロード を利用したいので、フロントエンドの開発サーバを経由 してバックエンドへアクセスするように設定する。
/react/webpack.config.js(フロントエンド側)
devServer: {
proxy: {
'/api': 'http://localhost:3000',
},
これで /api 以下のアクセスが開発サーバからバックエンドへ転送される。
参考リンク How To Get Started with the MERN Stack | DigitalOcean
バックエンド
REST API を提供するように、Controller のコードを変更していく。例えば Book(本)の場合は下の表のようになる。フロントエンドを分離すると、フロントエンド側から新たに 依存関係を取得するアクション が必要とされるので、list() 関数に機能を組み込む。
URL | Method | Action | Controller |
---|---|---|---|
/api/books/ | POST | 本を新規作成 | book_create() 関数 |
/api/books | GET | 本のリストを取得 | book_list() 関数 |
/api/books?authorId=[id]&genreId=[id] | GET | 指定した著者やジャンルの本 のリストを取得 | book_list() 関数 |
/api/books/:id | GET | 指定した本を取得 | book_detail() 関数 |
/api/books/:id | PUT | 指定した本を更新 | book_update() 関数 |
/api/books/:id | DELETE | 指定した本を削除 | book_delete() 関数 |
基本的には res.render() メソッド で出力していたレスポンスを、res.json() メソッド で出力するように書き換えていけば良い。
book_list() 関数
exports.book_list = async function (req, res) {
let query = new Object()
if (req.query.authorId) {
query["author"] = req.query.authorId
}
if (req.query.genreId) {
query["genre"] = req.query.genreId
}
try {
const list_books = await Book.find(query)
.sort("title")
//.select('title author')
.populate("author")
.exec()
//res.json(list_books);
res.json(list_books.map(book => book.toJSON({ virtuals: true })))
} catch (err) {
res.status(400).json("Error: " + err)
}
}
注意点としては、list() 関数と detail() 関数で Mongoose の Virtuals プロパティ も出力されるように変更が必要がある。例えば、著者の姓と名を連結した文字列が得られたりする Virtual プロパティが、res.json() ではデフォルトで出力されないので、
res.json(book.toJSON({ virtuals: true }))
のように toJSON()
メソッドを追加する必要がある。出力するのがリストの場合は、更に map()
メソッドを追加する。
res.json(list_books.map(book => book.toJSON({ virtuals: true })))
book_update() 関数
exports.book_update = [
// Convert the genre to an array
(req, res, next) => {
if (!(req.body.genre instanceof Array)) {
if (req.body.genre === "undefined") {
req.body.genre = []
} else {
req.body.genre = new Array(req.body.genre)
}
}
next()
},
// Validate and sanitise fields.
body("title", "Title must not be empty.")
.trim()
.isLength({ min: 1 })
.escape(),
body("author", "Author must not be empty.")
.trim()
.isLength({ min: 1 })
.escape(),
body("summary", "Summary must not be empty.")
.trim()
.isLength({ min: 1 })
.escape(),
body("isbn", "ISBN must not be empty.").trim().isLength({ min: 1 }).escape(),
body("genre.*").escape(),
async function (req, res) {
// Extract the validation errors from a request.
const errors = validationResult(req)
// There are errors. Render form again with sanitized values/error messages.
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() })
}
// Create a Book object with escaped/trimmed data and old id.
const book = new Book({
title: req.body.title,
author: req.body.author,
summary: req.body.summary,
isbn: req.body.isbn,
genre: req.body.genre,
_id: req.params.id, //This is required, or a new ID will be assigned!
})
try {
// Data from form is valid. Update the record.
await Book.findByIdAndUpdate(book._id, book).exec()
return res.json(book)
} catch (err) {
return res.status(400).json("Error: " + err)
}
},
]
元々混在していたビューのロジックが一切除去されるので(元のコードの解説はこちらを参照)、全体的に実装がシンプルになった。
フロントエンド
React Router, axios, Bootstrap を使うので、まずパッケージをインストールする。
$ npm install react-router-dom axios bootstrap
ディレクトリ構成
- react/src/
- App.js: アプリトップ(ルーティング)
- DynamicContent.js: トップページのコンポーネント
- SideBar.js: サイドバーのコンポーネント
- author/: 著者の関連コンポーネント
- book/: 本の関連コンポーネント
- bookinstance/: 本の現物の関連コンポーネント
- genre/: ジャンルの関連コンポーネント
- http-common.js: Axios の初期化処理
- index.js
- services/: API 通信処理
- BookService.js など
- utils.js: ユーティリティ関数など
ルーティング
各種ルーティングを実装していく。例えば本の場合は、パスとコンポーネントの対応関係は次のようになる。
パス | 機能 | メイン・コンポーネント |
---|---|---|
/books | 本のリストを表示 | book/BookList |
/book/:id | 本を詳細表示 | book/BookDetail |
/book/create | 本を追加 | book/BookCreate → book/BookForm |
/book/:id/update | 本を更新 | book/BookUpdate → book/BookForm |
/book/:id/delete | 本を削除 | book/BookDelete |
/react/src/App.js
export default function App() {
return (
<Router>
<div className="container-fluid">
<div className="row">
<div className="col-sm-2">
<Sidebar />
</div>
<div className="col-sm-10">
<Switch>
<Route path="/" exact>
<DynamicContent />
</Route>
<Route path="/books" exact>
<BookList />
</Route>
<Route path="/book/create" exact>
<BookCreate />
</Route>
<Route path="/book/:id" exact>
<BookDetail />
</Route>
<Route path="/book/:id/update" exact>
<BookUpdate />
</Route>
<Route path="/book/:id/delete" exact>
<BookDelete />
</Route>
// 省略
データサービス
バックエンドとの通信処理は axios を利用するが、React Hooks CRUD example with Axios and Web API - BezKoder を参考にして、通信処理の責務を負う DataService を作成する。
react/src/http-common.js: Axios の初期化処理
import axios from "axios"
export default axios.create({
baseURL: "http://localhost:8080/api",
headers: {
"Content-type": "application/json",
},
})
react/src/services/BookService.js: 本の関連データサービス
import http from "../http-common"
const urlPrefix = "/books"
const getAll = () => {
return http.get(urlPrefix)
}
const get = id => {
return http.get(`${urlPrefix}/${id}`)
}
const create = data => {
return http.post(urlPrefix, data)
}
const update = (id, data) => {
return http.put(`${urlPrefix}/${id}`, data)
}
const remove = id => {
return http.delete(`${urlPrefix}/${id}`)
}
const findByAuthorId = authorId => {
return http.get(`${urlPrefix}?authorId=${authorId}`)
}
const findByGenreId = genreId => {
return http.get(`${urlPrefix}?genreId=${genreId}`)
}
UI コンポーネントからは axios を直接利用せず、このデータサービスを利用するようにする。このように UI と バックエンドを分離 しておくと、バックエンドの仕様に変更があった場合にも対処しやすくなる。
コンポーネントの実装
いよいよ、フロントエンドの実装に進む。基本的には対応する PUG のテンプレートを JSX に変換してコンポーネントを作成 していく単純作業なのだが、合計 21 ページ あるので 気合を入れて 行こう!!
BookList コンポーネント
BookService.get()
関数で、バックエンドから本のリストを取得する。取得した本のリストは、useState フック を利用して定義されたローカル状態変数にセットされる。これらの一連の処理は getBookList() 関数で定義されており、コンポーネントのレンダリング後に一度だけ実行されるように、useEffect フックで指定されている。
react/src/book/BookList.js
import React, { useState, useEffect } from "react"
import { Link } from "react-router-dom"
import BookService from "../services/BookService"
export default function BookList() {
// ローカル状態変数: 本のリスト
const [books, setBooks] = useState([])
// コンポーネントのレンダリング後に一度だけ getBookList() 関数を呼ぶ
useEffect(() => {
getBookList()
}, [])
// 本のリストを取得して、ローカル状態にセットする
async function getBookList() {
try {
const res = await BookService.getAll()
setBooks(res.data)
} catch (err) {
console.log(err)
}
}
const list =
books.length === 0 ? (
<li>There are no books.</li>
) : (
books.map(item => {
return (
<li key={item._id}>
<Link to={`/book/${item._id}`}>{item.title}</Link>
<span> </span>({item.author.name})
</li>
)
})
)
return (
<>
<h1>Books List</h1>
<ul>{list}</ul>
</>
)
}
BookDetail コンポーネント
BookService を利用して、本の詳細情報を取得する流れは BookList コンポーネントと同じだが、更にその本に対応する(依存関係にある) 本の現物のリスト も取得する必要がある。本の現物のリストは BookInstanceService.findByBookId()
関数を利用して取得する。
後半はそれぞれの情報を JSX に変換して出力する処理を記述する。
- bookDetail: 本の詳細表示
- bookInstanceList 本の現物のリスト表示
react/src/book/BookDetail.js
import React, { useState, useEffect } from "react"
import { useParams, Link } from "react-router-dom"
import BookService from "../services/BookService"
import BookInstanceService from "../services/BookInstanceService"
import InlineGenreList from "../genre/InlineGenreList"
import StatusView from "../bookinstance/StatusView"
import DueBackView from "../bookinstance/DueBackView"
export default function BookDetail() {
const { id } = useParams()
// ローカル状態変数: 本
const [book, setBook] = useState()
// コンポーネントのレンダリング後に一度だけ getBook() 関数を呼ぶ
useEffect(() => {
getBook()
}, [])
// 本の情報を取得して、ローカル状態にセットする
async function getBook() {
try {
const res = await BookService.get(id)
setBook(res.data)
} catch (err) {
console.log(err)
}
}
// ローカル状態変数: 本の現物のリスト
const [dependings, setDependings] = useState([])
// コンポーネントのレンダリング後に一度だけ findBookInstance() 関数を呼ぶ
useEffect(() => {
findBookInstance()
}, [])
// 本の現物のリストを取得して、ローカル状態にセットする
async function findBookInstance() {
try {
const res = await BookInstanceService.findByBookId(id)
setDependings(res.data)
} catch (err) {
console.log(err)
}
}
// 本の詳細表示を作成
if (!book) return <></>
const bookDetail = book ? (
<>
<h1>Title: {book.title}</h1>
<p>
<strong>Author:</strong>
<Link to={`/author/${book.author._id}`}> {book.author.name}</Link>
</p>
<p>
<strong>Summary:</strong> {book.summary}
</p>
<p>
<strong>ISBN:</strong> {book.isbn}
</p>
<p>
<strong>Genre:</strong> <InlineGenreList genre={book.genre} />
</p>
</>
) : (
""
)
// 本の現物のリスト表示を作成
let bookInstanceList
if (dependings.length === 0) {
// No Deps Message
bookInstanceList = <p>There are no copies of this book in the library.</p>
} else {
// Item List with listItemElement
bookInstanceList = dependings.map(item => {
return (
<div key={item._id}>
<hr />
<StatusView viewType="p" status={item.status} />
<p>
<strong>Imprint:</strong> {item.imprint}
</p>
<DueBackView
status={item.status}
dueBackFormatted={item.due_back_formatted}
viewType="box"
/>
<p>
<strong>Id:</strong>
<Link to={`/bookinstance/${item.id}`}> {item.id}</Link>
</p>
</div>
)
})
}
const url = `/${"book"}/${id}`
return (
<>
{bookDetail}
<div className="list">
<h4>Copies</h4>
<dl>{bookInstanceList}</dl>
</div>
<hr />
<p>
<Link to={`${url}/delete`}>Delete Book</Link>
</p>
<p>
<Link to={`${url}/update`}>Update Book</Link>
</p>
</>
)
}
BookCreate コンポーネント
BookCreate コンポーネントは、フォームを含む BookForm コンポーネントを含むのみ。BookCreate コンポーネントと BookUpdate コンポーネントで フォームは同じ なので、元の MDN チュートリアルと同様に、共通部分を独立した BookForm コンポーネントとして共有 することで、実装を効率化(コード量を削減) している。
react/src/book/BookCreate.js
import React from "react"
import BookForm from "./BookForm"
const BookCreate = () => {
return (
<>
<h1>Create Book</h1>
<BookForm />
</>
)
}
export default BookCreate
BookForm コンポーネントでは、フォームを Controlled Form として作成している。フォームの初期値は、コンポーネントのプロパティ で予め指定する事ができる。この機能は次の節で説明する BookUpdate コンポーネントで利用される。
また、著者はプルダウンメニュー、ジャンルはチェックボックスとして構成するので、それぞれの選択肢のリストをバックエンドから取得しておく必要がある。これは BookList コンポーネントと同様に、各データサービスの getAll()
関数を利用して実現する。
フォームハンドラでは、コンポーネントの id プロパティの指定有無で、新規作成(create()
関数)または更新(update()
関数)で アクションを分岐 させている。これで新規作成にも更新にも使えるフォームが完成!!
react/src/book/BookForm.js
import React, { useState, useEffect } from "react"
import PropTypes from "prop-types"
import { useHistory } from "react-router-dom"
import BookService from "../services/BookService"
import AuthorService from "../services/AuthorService"
import GenreService from "../services/GenreService"
import FormErrors from "../FormErrors"
const BookForm = props => {
// 局所状態変数: フォームの入力値
const [title, setTitle] = useState(props.title)
const [author, setAuthor] = useState(props.author)
const [summary, setSummary] = useState(props.summary)
const [isbn, setIsbn] = useState(props.isbn)
const [genre, setGenre] = useState(props.genre)
const history = useHistory()
const [errors, setErrors] = useState([])
// フォームの選択肢を取得: Author
const [authorList, setAuthorList] = useState([])
useEffect(() => {
getAuthorList()
}, [])
async function getAuthorList() {
try {
const res = await AuthorService.getAll()
setAuthorList(res.data)
} catch (err) {
console.log(err)
}
}
// フォームの選択肢を取得: Genre
const [genreList, setGenreList] = useState([])
useEffect(() => {
getGenreList()
}, [])
async function getGenreList() {
try {
const res = await GenreService.getAll()
setGenreList(res.data)
} catch (err) {
console.log(err)
}
}
// フォームハンドラ
async function handleSubmit(event) {
event.preventDefault()
const newBook = new Object({
title: title,
author: author,
summary: summary,
isbn: isbn,
genre: genre,
})
try {
const res = await (props.id
? // id が指定されている場合 → 更新
BookService.update(props.id, newBook)
: // id が指定されてない場合 → 新規作成
BookService.create(newBook))
// 上手く言った場合は、本の詳細表示へ繊維
history.push(`/book/${res.data._id}`)
} catch (err) {
console.log(err)
if (err.response) {
// 入力値の検証エラーなどが発生した場合
setErrors(err.response.data.errors)
}
}
}
// チェックボックスのフォームハンドラ
function handleGenre(event) {
if (genre.includes(event.target.value)) {
setGenre(genre.filter(g => g !== event.target.value))
} else {
setGenre([...genre, event.target.value])
}
}
// フォームの選択肢表示を生成: Author
const authorOptions = authorList.map(author => {
return (
<option key={author._id} value={author._id}>
{author.name}
</option>
)
})
// フォームの選択肢表示を生成: Genre
const genreOptions = genreList.map(g => {
return (
<div className="form-check form-check-inline" key={g._id}>
<input
type="checkbox"
id={`genre-${g._id}`}
value={g._id}
checked={genre.includes(g._id)}
onChange={handleGenre}
className="form-check-input"
/>
<label htmlFor={`genre-${g._id}`} className="form-check-label">
{g.name}
</label>
</div>
)
})
return (
<>
<form method="POST" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Title:</label>
<input
type="text"
id="title"
name="title"
placeholder="Name of book"
value={title}
onChange={e => setTitle(e.target.value)}
className="form-control"
required
/>
</div>
<div className="form-group">
<label htmlFor="author">Author:</label>
<select
id="author"
name="author"
placeholder="Select author"
value={author}
onChange={e => setAuthor(e.target.value)}
className="form-control"
required
>
<option hidden value="">
Select author
</option>
{authorOptions}
</select>
</div>
<div className="form-group">
<label htmlFor="summary">Summary:</label>
<textarea
id="summary"
name="summary"
placeholder="Summary"
value={summary}
onChange={e => setSummary(e.target.value)}
className="form-control"
required
/>
</div>
<div className="form-group">
<label htmlFor="isbn">ISBN:</label>
<input
type="text"
id="isbn"
name="isbn"
placeholder="ISBN13"
value={isbn}
onChange={e => setIsbn(e.target.value)}
className="form-control"
required
/>
</div>
<div className="form-group">
<label>Genre:</label>
<div>{genreOptions}</div>
</div>
<input className={"btn btn-primary"} type="submit" />
</form>
<FormErrors errors={errors} />
</>
)
}
BookForm.propTypes = {
id: PropTypes.string,
title: PropTypes.string,
author: PropTypes.string,
summary: PropTypes.string,
isbn: PropTypes.string,
genre: PropTypes.array,
}
BookForm.defaultProps = {
title: "",
author: "",
summary: "",
isbn: "",
genre: [],
}
export default BookForm
BookUpdate コンポーネント
前の節で触れたように、BookUpdate コンポーネントでは、本の詳細情報を BookForm コンポーネントのプロパティで渡す。そうすることで、フォームには現在の内容が表示されるようになる。
import React, { useState, useEffect } from "react"
import { useParams } from "react-router-dom"
import BookService from "../services/BookService"
import BookForm from "./BookForm"
const BookUpdate = () => {
const { id } = useParams()
// ローカル状態変数: 本
const [book, setBook] = useState()
// コンポーネントのレンダリング後に一度だけ getBook() 関数を呼ぶ
useEffect(() => {
getBook()
}, [])
// 本の情報を取得して、ローカル状態にセットする
async function getBook() {
try {
const res = await BookService.get(id)
setBook(res.data)
} catch (err) {
console.log(err)
}
}
if (!book) return <></>
return (
<>
<h1>Update Book</h1>
<BookForm
id={id}
title={book.title}
author={book.author._id}
summary={book.summary}
isbn={book.isbn}
genre={book.genre.map(g => g._id)}
/>
</>
)
}
export default BookUpdate
BookDelete コンポーネント
削除する際には、依存関係を破壊しないように注意 を払う必要がある。本の場合は BookDetail コンポーネントの同様に、依存関係にある本の現物のリストを取得し、そのリストが空、つまり 依存関係に問題がない場合のみ、削除を実行可能 にする。リストが空でない場合は、その本の現物のリストと、先にそれらを削除する事を促すメッセージを表示を表示する。
import React, { useState, useEffect } from "react"
import { useParams, useHistory, Link } from "react-router-dom"
import BookService from "../services/BookService"
import BookInstanceService from "../services/BookInstanceService"
export default function BookDelete() {
const { id } = useParams()
// ローカル状態変数: 本
const [book, setBook] = useState()
// コンポーネントのレンダリング後に一度だけ getBook() 関数を呼ぶ
useEffect(() => {
getBook()
}, [])
// 本の情報を取得して、ローカル状態にセットする
async function getBook() {
try {
const res = await BookService.get(id)
setBook(res.data)
} catch (err) {
console.log(err)
}
}
// ローカル状態変数: 本の現物のリスト
const [dependings, setDependings] = useState([])
// コンポーネントのレンダリング後に一度だけ findBookInstance() 関数を呼ぶ
useEffect(() => {
findBookInstance()
}, [])
// 本の現物のリストを取得して、ローカル状態にセットする
async function findBookInstance() {
try {
const res = await BookInstanceService.findByBookId(id)
setDependings(res.data)
} catch (err) {
console.log(err)
}
}
const history = useHistory()
// イベントハンドラ
async function handleClick() {
try {
await BookService.remove(id)
console.log("removeAuthor")
// 削除が成功した場合 → 本のリストに遷移
history.push(`/books`)
} catch (err) {
console.log(err)
}
}
if (!book) return <></>
return (
<>
<h1>Delete Book: {book.title}</h1>
{dependings.length > 0 ? (
<>
<p>
<strong>
Delete the following copies before attempting to delete this book.
</strong>
</p>
<div className="list">
<h4>Copies</h4>
<ul>
{dependings.map(item => {
return (
<li key={item._id}>
<Link to={`/bookinstance/${item._id}`}>
{item.book.title}
</Link>
</li>
)
})}
</ul>
</div>
</>
) : (
<>
<p>Do you really want to delete this book?</p>
<button
type="button"
onClick={handleClick}
className="btn btn-primary"
>
Delete
</button>
</>
)}
</>
)
}
以上のように、Author(著者)、Genre(ジャンル)、BookInstance(本の現物)に対しても、頑張って同様の実装を繰り返す。
まとめ
- 今までバラバラだった要素技術達が、一つに繋がったのは感慨深い
- 21 ページの実装は結構大変だった
- もっともっと場数を踏んで、心身ともに慣らしたい
今後の展開
- リファクタリング ← 次回!
- バックエンドへのクエリー処理 → カスタムフック
- コンポーネントの共通部分 → 共通コンポーネント
- フォームデータの検証
- バックエンドへのクエリー処理に React Query を使ってみる
- Redux を適用