このハンズオンを進めるためには react-simple フォルダに移動する必要があります。
このハンズオンを始める前に、 react-simple フォルダをVSCodeで開いてください。
このプロジェクトは、React を使用したソーシャルネットワーク(掲示板)アプリケーションの実装ガイドです。 まずダミーデータを使用してReactの基本的な機能を実装し、その後にバックエンドAPI(localhost:9999)と連携して完全な動作を実現します。
- 投稿の一覧表示とページネーション
- 新規投稿の作成
- 投稿への いいね 機能
- コメントの表示と追加(モーダル)
- 投稿の削除
- ダークモード対応
- Node.js がインストールされていること
- React の基本的な知識があること
- JSX、useState、useEffect の基本的な使い方を知っていること
- React Hooks を使用した状態管理
- コンポーネント間のデータフロー
- イベントハンドリングとフォーム処理
- 条件付きレンダリング
- API連携の基本
目標: 基本的なVite+React環境を準備し、必要な依存関係をインストール
ターミナルの立ち上げ方を参考に、VSCodeでターミナルを立ち上げてください。
# 現在のディレクトリで依存関係を確認
npm install# 開発サーバーを起動
npm run devReactで重要なファイルを確認します.
src/main.jsx- React アプリケーションの読み込みポイント(Cのmain関数のようなもの)src/App.jsx- メインアプリケーションコンポーネント(実際のアプリケーションが動いているコンポーネント)src/utils/- ユーティリティ関数(日付フォーマット、ローカルストレージ)
目標: API連携前に使用するダミーデータを作成
mkdir -p src/datasrc/data/dummyData.js を作成し、以下のソースコードを貼り付けて保存して下さい。
// ダミーデータの管理
let nextId = 4;
export const dummyPosts = [
{
id: 1,
author: 'Alice',
content: 'こんにちは!今日はいい天気ですね。',
created_at: new Date(Date.now() - 3600000).toISOString(), // 1時間前
like_count: 3,
comment_count: 2,
comments: [
{
id: 1,
author: 'Bob',
content: 'そうですね!散歩日和です。',
created_at: new Date(Date.now() - 1800000).toISOString(),
},
{
id: 2,
author: 'Charlie',
content: '私も外に出かけたいです。',
created_at: new Date(Date.now() - 900000).toISOString(),
},
],
},
{
id: 2,
author: 'Bob',
content: 'Reactの勉強をしています。難しいですが楽しいです!',
created_at: new Date(Date.now() - 7200000).toISOString(), // 2時間前
like_count: 5,
comment_count: 1,
comments: [
{
id: 3,
author: 'Alice',
content: 'がんばって!応援してます。',
created_at: new Date(Date.now() - 3600000).toISOString(),
},
],
},
{
id: 3,
author: 'Charlie',
content: 'みなさんこんばんは!今日も一日お疲れ様でした。',
created_at: new Date(Date.now() - 10800000).toISOString(), // 3時間前
like_count: 2,
comment_count: 0,
comments: [],
},
];
export function addPost(author, content) {
const newPost = {
id: nextId++,
author,
content,
created_at: new Date().toISOString(),
like_count: 0,
comment_count: 0,
comments: [],
};
dummyPosts.unshift(newPost);
return newPost;
}
export function deletePost(postId) {
const index = dummyPosts.findIndex(post => post.id === parseInt(postId));
if (index !== -1) {
dummyPosts.splice(index, 1);
return true;
}
return false;
}
export function toggleLike(postId, increment = true) {
const post = dummyPosts.find(post => post.id === parseInt(postId));
if (post) {
post.like_count += increment ? 1 : -1;
post.like_count = Math.max(0, post.like_count);
}
}
export function addComment(postId, author, content) {
const post = dummyPosts.find(post => post.id === parseInt(postId));
if (post) {
const newComment = {
id: Date.now(),
author,
content,
created_at: new Date().toISOString(),
};
post.comments.push(newComment);
post.comment_count = post.comments.length;
return newComment;
}
return null;
}
export function getPaginatedPosts(page = 1, limit = 10) {
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedPosts = dummyPosts.slice(startIndex, endIndex);
return {
posts: paginatedPosts,
pagination: {
page,
limit,
totalCount: dummyPosts.length,
totalPages: Math.ceil(dummyPosts.length / limit),
hasNext: endIndex < dummyPosts.length,
hasPrev: page > 1,
},
};
}
export function getPostById(postId) {
return dummyPosts.find(post => post.id === parseInt(postId));
}目標: SNSの投稿表示に必要な基本コンポーネントを作成していきます.
フォルダ位置:react-simpleであることを確認
mkdir -p src/components1つ1つの投稿を表示するコンポーネントを実装します。
src/components/Post.jsx を作成:
import { formatDate } from '../utils/date.js';
function Post({ post, currentUser, onLike, onComment, onDelete }) {
// サーバーからisLikedが提供されている場合はそれを使用、なければfalse
const liked = post.isLiked || false;
return (
<div className="post">
<div className="post-header">
<span className="post-author">{post.author}</span>
<span className="post-date">{formatDate(post.created_at)}</span>
</div>
<div className="post-content">{post.content}</div>
<div className="post-actions">
<button
className={`like-btn ${liked ? 'liked' : ''}`}
onClick={() => onLike(post.id)}
>
<span className="like-icon">{liked ? '❤️' : '🤍'}</span>
<span className="like-count">{post.like_count || 0}</span>
</button>
<button className="comment-btn" onClick={() => onComment(post.id)}>
💬 {post.comment_count || 0}
</button>
{post.author === currentUser && (
<button className="delete-btn" onClick={() => onDelete(post.id)}>
🗑️ 削除
</button>
)}
</div>
</div>
);
}
export default Post;投稿をリスト形式で表示するコンポーネントを作ります。
src/components/PostList.jsx を作成:
import Post from './Post';
function PostList({
posts,
currentUser,
onLike,
onComment,
onDelete,
onPageChange,
}) {
if (!posts || posts.length === 0) {
return <div className="empty">投稿がありません。</div>;
}
return (
<div className="posts-container">
<h2>タイムライン</h2>
<div className="posts-list">
{posts.map(post => (
<Post
key={post.id}
post={post}
currentUser={currentUser}
onLike={onLike}
onComment={onComment}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export default PostList;目標: 新規投稿を作成するフォームコンポーネントを実装
src/components/PostForm.jsx を作成:
import { useState } from 'react';
function PostForm({ currentUser, onSubmit }) {
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async e => {
e.preventDefault();
if (!content.trim()) {
alert('投稿内容を入力してください。');
return;
}
setIsSubmitting(true);
try {
await onSubmit(content.trim());
setContent('');
} catch (error) {
alert('投稿の作成に失敗しました。');
} finally {
setIsSubmitting(false);
}
};
const handleKeyPress = e => {
if (e.key === 'Enter' && e.ctrlKey) {
handleSubmit(e);
}
};
return (
<div className="new-post-container">
<h2>新しい投稿</h2>
<form onSubmit={handleSubmit}>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="今何してる?"
rows={4}
/>
<button type="submit">投稿する</button>
</form>
</div>
);
}
export default PostForm;目標: App.jsx で全体の状態を管理し、コンポーネント間のデータフローを実装
src/App.jsx を更新:
import { useState, useEffect } from 'react';
import './App.css';
import PostForm from './components/PostForm';
import PostList from './components/PostList';
import {
getPosts,
createPost,
deletePost,
addLike,
removeLike,
} from './services/postService';
function App() {
const [posts, setPosts] = useState([]);
const [currentUser, setCurrentUser] = useState('ゲスト');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [commentModalOpen, setCommentModalOpen] = useState(false);
const [selectedPostId, setSelectedPostId] = useState(null);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
totalCount: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
});
useEffect(() => {
loadPosts(1);
}, []);
const loadPosts = async (page = pagination.page) => {
try {
setIsLoading(true);
setError(null);
const data = await getPosts(page, pagination.limit, currentUser);
setPosts(data.posts);
setPagination(data.pagination);
} catch (err) {
console.error('投稿の取得に失敗しました:', err);
setError('投稿の取得に失敗しました');
} finally {
setIsLoading(false);
}
};
const handleCreatePost = async (author, content) => {
await createPost(author, content);
await loadPosts(1);
};
const handleDeletePost = async postId => {
if (!confirm('この投稿を削除しますか?')) {
return;
}
try {
await deletePost(postId);
await loadPosts();
} catch (error) {
console.error('投稿の削除に失敗しました:', error);
alert('投稿の削除に失敗しました。');
}
};
const handleToggleLike = async postId => {
const post = posts.find(p => p.id === postId);
const liked = post?.isLiked || false;
try {
if (liked) {
await removeLike(postId, currentUser);
} else {
await addLike(postId, currentUser);
}
await loadPosts();
} catch (error) {
console.error('いいねの更新に失敗しました:', error);
}
};
const handleOpenComments = postId => {
setSelectedPostId(postId);
setCommentModalOpen(true);
};
const handleCloseComments = () => {
setCommentModalOpen(false);
setSelectedPostId(null);
};
const handleCommentAdded = async () => {
await loadPosts();
};
const handlePageChange = page => {
loadPosts(page);
};
return (
<div className="app">
<header>
<h1>SNS掲示板</h1>
<div className="user-info">
<label>ユーザー名: </label>
<input
type="text"
value={currentUser}
onChange={e => setCurrentUser(e.target.value)}
placeholder="名前を入力"
/>
</div>
</header>
<main>
<PostForm currentUser={currentUser} onSubmit={handleCreatePost} />
<PostList
posts={posts}
currentUser={currentUser}
onLike={handleToggleLike}
onComment={handleOpenComments}
onDelete={handleDeletePost}
isLoading={isLoading}
error={error}
pagination={pagination}
onPageChange={handlePageChange}
/>
</main>
</div>
);
}
export default App;目標: 投稿一覧のページネーション機能を実装
src/components/Pagination.jsx を作成:
import './Pagination.css';
function Pagination({ pagination, onPageChange }) {
const { page, totalPages, hasNext, hasPrev, totalCount } = pagination;
if (totalPages <= 1) {
return null;
}
const handlePageClick = newPage => {
if (newPage >= 1 && newPage <= totalPages) {
onPageChange(newPage);
}
};
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="pagination">
<div className="pagination-info">
{totalCount}件中 {(page - 1) * pagination.limit + 1}-
{Math.min(page * pagination.limit, totalCount)}件を表示
</div>
<div className="pagination-controls">
<button
onClick={() => handlePageClick(page - 1)}
disabled={!hasPrev}
className="pagination-button"
>
前へ
</button>
{getPageNumbers().map(pageNum => (
<button
key={pageNum}
onClick={() => handlePageClick(pageNum)}
className={`pagination-button ${page === pageNum ? 'active' : ''}`}
>
{pageNum}
</button>
))}
<button
onClick={() => handlePageClick(page + 1)}
disabled={!hasNext}
className="pagination-button"
>
次へ
</button>
</div>
</div>
);
}
export default Pagination;PostList.jsxを以下のように変更して下さい。
import Post from './Post';
+ import Pagination
function PostList({
posts,
currentUser,
onLike,
onComment,
onDelete,
onPageChange,
+ pagination
}) {
if (!posts || posts.length === 0) {
return <div className="empty">投稿がありません。</div>;
}
return (
<div className="posts-container">
<h2>タイムライン</h2>
<div className="posts-list">
{posts.map(post => (
<Post
key={post.id}
post={post}
currentUser={currentUser}
onLike={onLike}
onComment={onComment}
onDelete={onDelete}
/>
))}
</div>
+ {pagination && (
+ <Pagination pagination={pagination} onPageChange={onPageChange} />
+ )}
</div>
);
}
export default PostList;目標: モーダルウィンドウでのコメント表示・追加機能を実装
src/components/Comment.jsx を作成:
import { formatDate } from '../utils/date.js';
function Comment({ comment }) {
return (
<div className="comment">
<div className="comment-header">
<span className="comment-author">{comment.author}</span>
<span className="comment-date">{formatDate(comment.created_at)}</span>
</div>
<div className="comment-content">{comment.content}</div>
</div>
);
}
export default Comment;src/components/CommentModal.jsx を作成:
import { useState } from 'react';
import Comment from './Comment.jsx';
import { formatDate } from '../utils/date.js';
function CommentModal({ post, currentUser, onClose, onAddComment }) {
const [commentContent, setCommentContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async e => {
e.preventDefault();
if (!commentContent.trim()) {
alert('コメントを入力してください。');
return;
}
setIsSubmitting(true);
try {
await onAddComment(post.id, commentContent.trim());
setCommentContent('');
} catch (error) {
alert('コメントの追加に失敗しました。');
} finally {
setIsSubmitting(false);
}
};
const handleKeyPress = e => {
if (e.key === 'Enter' && e.ctrlKey) {
handleSubmit(e);
}
};
return (
<div className="comment-modal" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>コメント</h3>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="modal-body">
<div className="original-post">
<div className="post-header">
<span className="post-author">{post.author}</span>
<span className="post-date">{formatDate(post.created_at)}</span>
</div>
<div className="post-content">{post.content}</div>
</div>
<div className="comments-section">
<h4>コメント ({post.comments.length})</h4>
<div className="comments-list">
{post.comments.length === 0 ? (
<p className="no-comments">コメントはまだありません。</p>
) : (
post.comments.map(comment => (
<Comment key={comment.id} comment={comment} />
))
)}
</div>
</div>
<form className="comment-form" onSubmit={handleSubmit}>
<textarea
value={commentContent}
onChange={e => setCommentContent(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="コメントを入力(Ctrl+Enterで投稿)"
rows="3"
disabled={isSubmitting}
/>
<div className="form-actions">
<span className="user-info">投稿者: {currentUser}</span>
<button
type="submit"
disabled={isSubmitting || !commentContent.trim()}
>
{isSubmitting ? '投稿中...' : 'コメントする'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
export default CommentModal;目標: 投稿の削除機能を実装
削除機能は既にStep 5で実装済みです。以下のポイントを確認:
- 投稿者本人のみが削除ボタンを表示
- 削除前に確認ダイアログを表示
- 削除後に投稿一覧を更新
目標: アプリケーションのスタイリングを完成させる
src/App.css を更新:
/* CSS変数の定義 */
:root {
--primary-color: #1976d2;
--secondary-color: #666;
--success-color: #4caf50;
--danger-color: #f44336;
--warning-color: #ff9800;
--light-color: #f5f5f5;
--dark-color: #333;
--border-color: #ddd;
--text-color: #333;
--bg-color: #fff;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--border-radius: 8px;
}
/* ダークモード */
@media (prefers-color-scheme: dark) {
:root {
--text-color: #f5f5f5;
--bg-color: #1a1a1a;
--light-color: #2a2a2a;
--border-color: #444;
}
}
/* 基本スタイル */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--bg-color);
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* ヘッダー */
.app-header {
background: var(--light-color);
padding: 20px;
border-radius: var(--border-radius);
margin-bottom: 20px;
box-shadow: var(--shadow);
}
.app-header h1 {
color: var(--primary-color);
margin-bottom: 10px;
}
.user-controls {
display: flex;
align-items: center;
gap: 10px;
}
.user-controls input {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
}
/* 投稿フォーム */
.post-form {
background: var(--light-color);
padding: 20px;
border-radius: var(--border-radius);
margin-bottom: 20px;
box-shadow: var(--shadow);
}
.post-form h2 {
color: var(--primary-color);
margin-bottom: 15px;
}
.post-form textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
resize: vertical;
font-family: inherit;
margin-bottom: 10px;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info {
font-size: 0.9em;
color: var(--secondary-color);
}
.post-form button {
background: var(--primary-color);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.post-form button:hover:not(:disabled) {
background: #1565c0;
}
.post-form button:disabled {
background: var(--secondary-color);
cursor: not-allowed;
}
/* 投稿セクション */
.posts-section h2 {
color: var(--primary-color);
margin-bottom: 15px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.post {
background: var(--light-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--shadow);
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.post-author {
font-weight: bold;
color: var(--primary-color);
}
.post-date {
color: var(--secondary-color);
font-size: 0.9em;
}
.post-content {
margin-bottom: 15px;
line-height: 1.6;
white-space: pre-wrap;
}
.post-actions {
display: flex;
gap: 10px;
}
.post-actions button {
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.like-btn {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.like-btn.liked {
background: #ffeaa7;
color: #d63031;
border-color: #d63031;
}
.comment-btn {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.comment-btn:hover {
background: var(--primary-color);
color: white;
}
.delete-btn {
background: var(--danger-color);
color: white;
}
.delete-btn:hover {
background: #d32f2f;
}
/* ページネーション */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 20px;
background: var(--light-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
.pagination-info {
font-size: 0.9em;
color: var(--secondary-color);
}
.pagination-controls {
display: flex;
gap: 5px;
}
.pagination-btn {
padding: 8px 12px;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: var(--primary-color);
color: white;
}
.pagination-btn.active {
background: var(--primary-color);
color: white;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* モーダル */
.comment-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-color);
border-radius: var(--border-radius);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
color: var(--primary-color);
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--secondary-color);
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--danger-color);
}
.modal-body {
padding: 20px;
}
.original-post {
background: var(--light-color);
padding: 15px;
border-radius: var(--border-radius);
margin-bottom: 20px;
}
.comments-section h4 {
color: var(--primary-color);
margin-bottom: 10px;
}
.comments-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 20px;
}
.comment {
background: var(--light-color);
padding: 15px;
border-radius: var(--border-radius);
margin-bottom: 10px;
border-left: 3px solid var(--primary-color);
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.comment-author {
font-weight: bold;
color: var(--primary-color);
}
.comment-date {
font-size: 0.8em;
color: var(--secondary-color);
}
.comment-content {
line-height: 1.5;
white-space: pre-wrap;
}
.no-comments {
text-align: center;
color: var(--secondary-color);
font-style: italic;
padding: 20px;
}
.comment-form {
border-top: 1px solid var(--border-color);
padding-top: 20px;
}
.comment-form textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
resize: vertical;
font-family: inherit;
margin-bottom: 10px;
}
/* 状態表示 */
.loading,
.error,
.empty {
text-align: center;
padding: 40px;
color: var(--secondary-color);
}
.error {
color: var(--danger-color);
}
/* レスポンシブデザイン */
@media (max-width: 768px) {
.app {
padding: 10px;
}
.post-header {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.post-actions {
flex-wrap: wrap;
}
.pagination {
flex-direction: column;
gap: 10px;
}
.pagination-controls {
flex-wrap: wrap;
justify-content: center;
}
.modal-content {
width: 95%;
margin: 10px;
}
.form-actions {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
}目標: エラーハンドリングとユーザーエクスペリエンスを向上させる
既にStep 5で基本的なエラーハンドリングを実装済み。以下を確認:
- try-catch文によるエラー処理
- ユーザーフレンドリーなエラーメッセージ
- ローディング状態の表示
- キーボードショートカット(Ctrl+Enter)
- 無効な操作の防止(空の投稿など)
- 即座のフィードバック(いいね状態の即時反映)
目標: ダミーデータからAPI連携に切り替え
# 別ターミナルで
npm run dev:backendsrc/services/postService.js を作成:
import { apiGet, apiPost, apiDelete } from '../utils/api.js';
export async function getPosts(page = 1, limit = 10) {
return apiGet(`/posts?page=${page}&limit=${limit}`);
}
export async function getPost(postId) {
return apiGet(`/posts/${postId}`);
}
export async function createPost(author, content) {
return apiPost('/posts', { author, content });
}
export async function deletePost(postId) {
return apiDelete(`/posts/${postId}`);
}
export async function addLike(postId, userId) {
return apiPost(`/posts/${postId}/likes`, { userId });
}
export async function removeLike(postId, userId) {
return apiDelete(`/posts/${postId}/likes`, { userId });
}
export async function addComment(postId, author, content) {
return apiPost(`/posts/${postId}/comments`, { author, content });
}ダミーデータの関数をpostServiceの関数に置き換え:
// import文を変更
import {
getPosts,
getPost,
createPost,
deletePost,
addLike,
removeLike,
addComment,
} from './services/postService.js';
// 関数をasync/awaitに変更
const loadPosts = async (page = currentPage) => {
setLoading(true);
setError(null);
try {
const data = await getPosts(page, pagination.limit);
setPosts(data.posts);
setPagination(data.pagination);
setCurrentPage(page);
} catch (err) {
setError('投稿の読み込みに失敗しました。');
} finally {
setLoading(false);
}
};
// 他の関数も同様に修正...- useState: コンポーネントの状態管理
- useEffect: 副作用の処理(データ取得など)
- カスタムフック: 共通ロジックの抽出
- 単一責任の原則: 各コンポーネントが一つの責任を持つ
- Props: 親から子へのデータ受け渡し
- イベントハンドリング: 子から親への情報伝達
- リフトアップ: 状態を共通の親コンポーネントに移動
- 状態の正規化: 重複を避けた状態設計
- 派生状態: 既存の状態から計算される値
- 条件付きレンダリング: 必要な時のみコンポーネントを描画
- キーの適切な使用: リストレンダリングの最適化
- 不要な再レンダリングの防止: React.memo、useMemo、useCallback
- コンポーネント: UIの再利用可能な部品
- JSX: JavaScriptの拡張構文
- 仮想DOM: 効率的なDOM更新
- 状態管理: アプリケーションの状態を適切に管理
- イベント処理: ユーザーインタラクションの処理
- API連携: 外部サービスとの通信
- 関数コンポーネント: クラスコンポーネントより簡潔
- Hooks: 状態管理と副作用の処理
- ES6+: モダンなJavaScript機能の活用
このガイドに従って段階的に実装することで、React を使用した本格的なWebアプリケーションの開発スキルを身につけることができます。