React 入門
JavaScript コメント掲示板アプリの開発
プログラムの全体像
前のページまでで API からコメントを取得して一覧表示・個別表示したり,編集,削除ができる React のフロントエンドアプリケーションが完成しました.これまで断片的に示していたコードもあるのでここではプロジェクトのほぼ全体のソースコードを示しておきます.なお,同じような機能を実装する方法は他にも様々考えられるので,一つの例として捉えてください.
public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React Comment アプリケーション</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import routes from './routers/routes';
import './index.css';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<RouterProvider router={routes} />
</React.StrictMode>
);
// 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();
src/index.css
body {
margin: 0;
color: #333;
}
.container {
margin: 20px 20px;
}
h1 {
margin: 20px 0;
}
article {
margin: 20px 0;
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
}
article .title {
font-size: 1.2em;
font-weight: bold;
}
article .body {
font-size: 0.9em;
line-height: 1.5em;
}
.textInput {
width: 100%;
padding: 4px;
margin: 0 0 8px;
box-sizing: border-box;
}
.deleteButton {
text-align: right;
}
.commentsHeader {
font-size: 0.8em;
}
.infinityScroll {
text-align: center;
margin-bottom: 20px;
}
src/routers/routes.js
import { Route, createBrowserRouter, createRoutesFromElements } from 'react-router-dom';
import CommentListPage from '../components/CommentListPage';
import CommentShowPage from '../components/CommentShowPage';
import CommentEditPage from '../components/CommentEditPage';
import NotFoundPage from '../components/NotFoundPage';
const routes = createBrowserRouter(
createRoutesFromElements(
<Route>
<Route path="/" element={<CommentListPage />} />
<Route path="/show/:commentId" element={<CommentShowPage />} />
<Route path="/edit/:commentId" element={<CommentEditPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
)
);
export default routes;
src/components/CommentListPage.js
import React from 'react'
import { useState, useEffect } from 'react';
import axios from 'axios';
import Comment from './Comment';
import CreateForm from './CreateForm';
const CommentListPage = () => {
const [results, setResults] = useState({
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9999,
"title":"ダミー",
"body":"ダミーの本文",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10000,
"title":"ダミーのタイトル",
"body":"ダミーです",
"updated_at":"2023-11-21T11:10:00"
}
]
});
// const [isPrevButtonDisabled, setPrevButtonDisabled] = useState(true);
// const [isNextButtonDisabled, setNextButtonDisabled] = useState(false);
const handleCommentsListChange = (url) => {
axios.get(url)
.then(res => setResults(res.data))
.catch(err => console.log(err.message));
}
useEffect(() => {
const url = "http://127.0.0.1:8000/comments/";
axios.get(url)
.then(res => setResults(res.data))
.catch(err => console.log(err.message));
}, []);
const handleDeleteButtonClick = (commentId) => {
if (!window.confirm("削除しますか?")) {
return;
}
const newComments = [...results.results].filter((comment) => {
return comment.id !== commentId;
});
const newResults = {
"count": results.count - 1, // コメント数は1減らす
"previous": results.previous,
"next" : results.next,
"results": newComments
};
setResults(newResults);
const url = `http://127.0.0.1:8000/comments/${commentId}/`;
axios
.delete(url)
.then(res => {
if (res.status === 204) {
handleCommentsListChange('http://127.0.0.1:8000/comments/');
} else {
console.log("Delete失敗");
}
})
.catch(err => console.log(err.message));
};
const commentItems = results.results.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
onDelete={handleDeleteButtonClick}
/>
)
});
const handelCreateFormSubmit = async (title, body) => {
const newComments = [...results.results]; // スプレッド構文でコメントの配列だけを取り出す
newComments.unshift({ // unshift で先頭に追加,push では最後に追加
id: Date.now(),
title: title,
body: body,
updated_at: "2024-02-25T15:30:00",
});
const newResults = {
"count": results.count + 1, // コメント数は1増やす
"previous": results.previous,
"next" : results.next,
"results": newComments, // これがコメントの配列
};
setResults(newResults); // 画面を更新
// API に POST でリクエストする
let result = false;
const url = "http://127.0.0.1:8000/comments/";
await axios
.post(url, {
title: title,
body: body,
})
.then(res => {
// console.log(res.data);
handleCommentsListChange(url);
result = true;
})
.catch(err => {
setResults(results); // 画面を元に戻す(元の配列で更新)
alert("投稿エラー!未入力または文字数超過です.");
});
return result;
}
// const handlePrevPageButton = () => {
// if (results.previous === null) {
// return;
// }
// axios.get(results.previous)
// .then(res => setResults(res.data))
// .catch(err => console.log(err.message));
// };
// const handleNextPageButton = () => {
// if (results.next === null) {
// return;
// }
// axios.get(results.next)
// .then(res => setResults(res.data))
// .catch(err => console.log(err.message));
// };
// useEffect(() => {
// setPrevButtonDisabled(results.previous === null);
// setNextButtonDisabled(results.next === null);
// }, [results]);
const handleInfinityScrollButton = () => {
axios.get(results.next)
.then(res => {
const prevComments = [...results.results]; // 表示済のコメント配列
const moreComments = [...res.data.results]; // 新たに読み込んだコメント配列
const newResults = {
"count": res.data.count,
"previous": null, // 前のページは null のまま
"next" : res.data.next, // 次のURLを更新
"results": prevComments.concat(moreComments), // 配列を連結
};
setResults(newResults); // 画面を更新
})
.catch(err => console.log(err.message));
}
return (
<div className="container">
<h1>コメント一覧</h1>
<div className="commentsHeader">
<div>コメント総数:{results.count}</div>
{/* <div>
<button
onClick={handlePrevPageButton}
disabled={isPrevButtonDisabled}
>前のページ</button>
<button
onClick={handleNextPageButton}
disabled={isNextButtonDisabled}
>次のページ</button>
</div> */}
</div>
{commentItems}
<div className="infinityScroll">
<button
onClick={handleInfinityScrollButton}
>
次を読み込む
</button>
</div>
<CreateForm
onSubmit={handelCreateFormSubmit}
/>
</div>
)
}
export default CommentListPage
src/components/Comment.js
import React from 'react'
import { Link } from 'react-router-dom';
const Comment = (props) => {
const handleClickDeleteButton = () => {
props.onDelete(props.comment.id);
};
return (
<article>
<div className="title">
<Link to={`/show/${props.comment.id}/`}>
{props.comment.title}
</Link>
</div>
<div className="body">
{props.comment.body}
</div>
<div className="deleteButton">
<button
onClick={handleClickDeleteButton}
>
削除
</button>
</div>
</article>
)
}
export default Comment
src/components/CreateForm.js
import React from 'react'
import { useState, useRef } from 'react';
const CreateForm = (props) => {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const inputTitleRef = useRef(null);
const handleTitleChange = (event) => {
setTitle(event.currentTarget.value);
};
const handleBodyChange = (event) => {
setBody(event.currentTarget.value);
};
const handleSubmit = async (event) => {
event.preventDefault(); // ページ遷移しないように
const result = await props.onSubmit(title, body);
if (result) {
setTitle(''); // 入力フォームを空に戻す
setBody(''); // 入力フォームを空に戻す
}
inputTitleRef.current.focus(); // フォーカスを当てる
}
return (
<>
<hr />
<article>
<h2>コメントの新規投稿</h2>
<form onSubmit={handleSubmit}>
<div>
<label>
タイトル:
<input
type="text"
className="textInput"
value={title}
onChange={handleTitleChange}
ref={inputTitleRef}
/>
</label>
</div>
<div>
<label>
本文:
<input
type="text"
className="textInput"
value={body}
onChange={handleBodyChange}
/>
</label>
</div>
<div>
<button>コメントの追加</button>
</div>
</form>
</article>
</>
)
}
export default CreateForm
src/components/CommentShowPage.js
import React from 'react'
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'; // Link を追加
import axios from 'axios';
const CommentShowPage = () => {
const params = useParams();
const [data, setData] = useState(null);
const url = `http://127.0.0.1:8000/comments/${params.commentId}/`;
useEffect(() => {
axios.get(url)
.then(res => setData(res.data))
.catch(err => console.log(err.message));
}, []);
const navigate = useNavigate();
const handleBackClick = () => {
navigate('/');
};
if (data === null) {
return (
<div className="container">
<h1>コメント</h1>
<article>
</article>
</div>
);
}
return (
<div className="container">
<h1>コメント</h1>
<article>
<div>ID: {data.id}</div>
<div className="title">Title: {data.title}</div>
<div className="body">Body: {data.body}</div>
<div>最終更新: {data.updated_at}</div>
<div>
<Link to={`/edit/${data.id}/`}>
コメント情報の編集
</Link>
</div>
</article>
<button
onClick={handleBackClick}
>コメント一覧へ戻る</button>
</div>
)
}
export default CommentShowPage
src/componenst/CommentEditPage.js
import React from 'react'
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import EditForm from './EditForm';
const CommentEditPage = () => {
const params = useParams();
const [data, setData] = useState(null);
const url = `http://127.0.0.1:8000/comments/${params.commentId}/`;
console.log(url);
useEffect(() => {
axios.get(url)
.then(res => setData(res.data))
.catch(err => console.log(err.message));
}, []);
const navigate = useNavigate();
const handleEditFormSubmit = (url, title, body) => {
axios
.put(url, {
title: title,
body: body,
})
.then(res => {
if (res.status === 200) {
console.log("PUT成功");
navigate('/');
} else {
console.log("PUT失敗");
}
})
.catch(err => {
console.log(err.message);
alert("投稿エラー!未入力または文字数超過です.");
});
};
if (data === null) {
return (
<div className="container">
<h1>コメントの編集</h1>
<article>
<p></p>
</article>
</div>
);
}
return (
<div className="container">
<h1>コメントの編集</h1>
<EditForm
commentId={data.id}
title={data.title}
body={data.body}
url={url}
onSubmit={handleEditFormSubmit}
/>
</div>
)
}
export default CommentEditPage
src/components/EditForm.js
import React from 'react'
import { useState } from 'react';
const EditForm = (props) => {
const [title, setTitle] = useState(props.title);
const [body, setBody] = useState(props.body);
const handleTitleChange = (event) => {
setTitle(event.currentTarget.value);
};
const handleBodyChange = (event) => {
setBody(event.currentTarget.value);
};
const formSubmit = (event) => {
event.preventDefault();
props.onSubmit(props.url, title, body);
console.log("EditForm : formSubmit");
}
return (
<>
<form onSubmit={formSubmit}>
<div>
<label>
タイトル:
<input
type="text"
className="textInput"
value={title}
onChange={handleTitleChange}
/>
</label>
</div>
<div>
<label>
本文:
<input
type="text"
className="textInput"
value={body}
onChange={handleBodyChange}
/>
</label>
</div>
<div>
<button>コメントの更新</button>
</div>
</form>
</>
)
}
export default EditForm
src/components/NotFoundPage.js
import React from 'react'
const NotFoundPage = () => {
return (
<div className="container">
<h1>404 Not Found</h1>
<p>指定したページは見つかりませんでした</p>
</div>
)
}
export default NotFoundPage