
React演習問題(JSX・コンポーネント・Props・State)
初級問題(9問) JSXの書き方 <div class="container"> <h1 id="title&q […]
これまで学んできたすべての技術要素を統合し、完全なフルスタックTODOアプリケーションを完成させましょう。今回は、Reactフロントエンド、Expressバックエンド、MySQLデータベース、Prisma ORM、JWT認証、CORS設定をすべて組み合わせた本格的なアプリケーションを構築します。
完成したアプリケーションの構造は以下のようになります:
todo-app/
├── backend/ # Expressバックエンド
│ ├── prisma/
│ │ └── schema.prisma # データベーススキーマ
│ ├── src/
│ │ ├── config/ # 設定ファイル
│ │ ├── middleware/ # カスタムミドルウェア
│ │ ├── models/ # データモデル(必要に応じて)
│ │ ├── routes/ # APIルート
│ │ ├── utils/ # ユーティリティ関数
│ │ └── lib/ # ライブラリ設定
│ ├── .env # 環境変数
│ └── server.js # メインサーバーファイル
├── frontend/ # Reactフロントエンド
│ ├── public/
│ ├── src/
│ │ ├── api/ # API通信モジュール
│ │ ├── components/ # Reactコンポーネント
│ │ ├── contexts/ # React Context
│ │ ├── hooks/ # カスタムフック
│ │ └── utils/ # ユーティリティ関数
│ ├── .env # 環境変数
│ └── package.json
└── docker-compose.yml # Docker設定(オプション)
// backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String?
todos Todo[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Todo {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("todos")
}
model RefreshToken {
id Int @id @default(autoincrement())
token String @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
# backend/.env
# データベース接続
DATABASE_URL="mysql://app_user:password@localhost:3306/todo_app"
# JWT設定
JWT_SECRET="your-super-secret-jwt-key-for-access-token-min-32-chars"
JWT_REFRESH_SECRET="your-super-secret-jwt-key-for-refresh-token-min-32-chars"
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# アプリケーション設定
NODE_ENV="development"
PORT=5000
CLIENT_URL=”http://localhost:3000″
# その他
BCRYPT_SALT_ROUNDS=12
// backend/src/utils/password.js
const bcrypt = require('bcryptjs');
const hashPassword = async (password) => {
const saltRounds = parseInt(process.env.BCRYPT_SALT_ROUNDS) || 12;
return await bcrypt.hash(password, saltRounds);
};
const verifyPassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
module.exports = {
hashPassword,
verifyPassword
};
// backend/src/utils/jwt.js
const jwt = require('jsonwebtoken');
const prisma = require('../lib/prisma');
const generateAccessToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
};
const generateRefreshToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
);
};
const verifyAccessToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
throw new Error('無効または期限切れのアクセストークンです');
}
};
const verifyRefreshToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
} catch (error) {
throw new Error('無効または期限切れのリフレッシュトークンです');
}
};
const storeRefreshToken = async (userId, token, expiresAt) => {
return await prisma.refreshToken.create({
data: {
token,
userId,
expiresAt
}
});
};
const revokeRefreshToken = async (token) => {
return await prisma.refreshToken.deleteMany({
where: { token }
});
};
const extractTokenFromHeader = (authHeader) => {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
};
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
storeRefreshToken,
revokeRefreshToken,
extractTokenFromHeader
};
// backend/src/middleware/auth.js
const { verifyAccessToken, extractTokenFromHeader } = require('../utils/jwt');
const prisma = require('../lib/prisma');
const authenticateUser = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(authHeader);
if (!token) {
return res.status(401).json({ error: '認証トークンが必要です' });
}
const decoded = verifyAccessToken(token);
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
name: true,
createdAt: true
}
});
if (!user) {
return res.status(401).json({ error: 'ユーザーが見つかりません' });
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({ error: error.message });
}
};
// オプションの認証(認証されていないユーザーも通す)
const optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(authHeader);
if (token) {
const decoded = verifyAccessToken(token);
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
name: true,
createdAt: true
}
});
if (user) {
req.user = user;
}
}
next();
} catch (error) {
// 認証エラーがあっても続行(オプション認証のため)
next();
}
};
module.exports = {
authenticateUser,
optionalAuth
};
// backend/src/routes/todos.js
const express = require('express');
const { authenticateUser } = require('../middleware/auth');
const prisma = require('../lib/prisma');
const router = express.Router();
// すべてのルートを認証で保護
router.use(authenticateUser);
// TODO一覧の取得(ページネーション付き)
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 10, completed } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const whereClause = { userId: req.user.id };
if (completed !== undefined) {
whereClause.completed = completed === 'true';
}
const [todos, total] = await Promise.all([
prisma.todo.findMany({
where: whereClause,
orderBy: { createdAt: 'desc' },
skip,
take: parseInt(limit),
include: {
user: {
select: {
id: true,
name: true,
email: true
}
}
}
}),
prisma.todo.count({ where: whereClause })
]);
res.json({
todos,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit))
}
});
} catch (error) {
console.error('TODO一覧取得エラー:', error);
res.status(500).json({ error: 'TODOの取得に失敗しました' });
}
});
// 特定のTODOの取得
router.get('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id);
const todo = await prisma.todo.findFirst({
where: {
id,
userId: req.user.id
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
if (!todo) {
return res.status(404).json({ error: 'TODOが見つかりません' });
}
res.json(todo);
} catch (error) {
console.error('TODO取得エラー:', error);
res.status(500).json({ error: 'TODOの取得に失敗しました' });
}
});
// TODOの新規作成
router.post('/', async (req, res) => {
try {
const { title, completed = false } = req.body;
if (!title || title.trim() === '') {
return res.status(400).json({ error: 'タイトルは必須です' });
}
const todo = await prisma.todo.create({
data: {
title: title.trim(),
completed,
userId: req.user.id
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
res.status(201).json(todo);
} catch (error) {
console.error('TODO作成エラー:', error);
res.status(500).json({ error: 'TODOの作成に失敗しました' });
}
});
// TODOの更新
router.put('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id);
const { title, completed } = req.body;
// TODOの存在確認と権限チェック
const existingTodo = await prisma.todo.findFirst({
where: {
id,
userId: req.user.id
}
});
if (!existingTodo) {
return res.status(404).json({ error: 'TODOが見つかりません' });
}
const todo = await prisma.todo.update({
where: { id },
data: {
title: title !== undefined ? title : existingTodo.title,
completed: completed !== undefined ? completed : existingTodo.completed
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
res.json(todo);
} catch (error) {
console.error('TODO更新エラー:', error);
res.status(500).json({ error: 'TODOの更新に失敗しました' });
}
});
// TODOの削除
router.delete('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id);
// TODOの存在確認と権限チェック
const existingTodo = await prisma.todo.findFirst({
where: {
id,
userId: req.user.id
}
});
if (!existingTodo) {
return res.status(404).json({ error: 'TODOが見つかりません' });
}
await prisma.todo.delete({
where: { id }
});
res.status(204).send();
} catch (error) {
console.error('TODO削除エラー:', error);
res.status(500).json({ error: 'TODOの削除に失敗しました' });
}
});
// 複数TODOの一括操作
router.patch('/batch', async (req, res) => {
try {
const { ids, completed } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: '有効なIDリストが必要です' });
}
if (completed === undefined) {
return res.status(400).json({ error: '完了状態を指定してください' });
}
const result = await prisma.todo.updateMany({
where: {
id: { in: ids },
userId: req.user.id
},
data: { completed }
});
res.json({
message: `${result.count}個のTODOを更新しました`,
updatedCount: result.count
});
} catch (error) {
console.error('一括更新エラー:', error);
res.status(500).json({ error: '一括更新に失敗しました' });
}
});
// 統計情報の取得
router.get('/stats/summary', async (req, res) => {
try {
const total = await prisma.todo.count({
where: { userId: req.user.id }
});
const completed = await prisma.todo.count({
where: {
userId: req.user.id,
completed: true
}
});
const pending = await prisma.todo.count({
where: {
userId: req.user.id,
completed: false
}
});
// 最近の活動
const recentActivity = await prisma.todo.findMany({
where: { userId: req.user.id },
orderBy: { updatedAt: 'desc' },
take: 5,
select: {
id: true,
title: true,
completed: true,
updatedAt: true
}
});
res.json({
total,
completed,
pending,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0,
recentActivity
});
} catch (error) {
console.error('統計情報取得エラー:', error);
res.status(500).json({ error: '統計情報の取得に失敗しました' });
}
});
module.exports = router;
// backend/server.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const corsOptions = require('./src/config/cors');
const authRoutes = require('./src/routes/auth');
const todoRoutes = require('./src/routes/todos');
const app = express();
const port = process.env.PORT || 5000;
// セキュリティミドルウェア
app.use(helmet());
app.use(cors(corsOptions));
// レート制限
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分
max: 100, // 各IPから15分間に100リクエストまで
message: 'リクエストが多すぎます。後ほどお試しください。'
});
app.use('/api/', limiter);
// ロギングミドルウェア
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// ボディパーサー
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// ルート設定
app.use('/api/auth', authRoutes);
app.use('/api/todos', todoRoutes);
// ヘルスチェック
app.get('/api/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV,
version: '1.0.0'
});
});
// 404ハンドリング
app.use('/api/*', (req, res) => {
res.status(404).json({ error: 'エンドポイントが見つかりません' });
});
// グローバルエラーハンドリング
app.use((error, req, res, next) => {
console.error('予期せぬエラー:', error);
// Prismaエラーの処理
if (error.code === 'P2002') {
return res.status(409).json({ error: '重複するデータが存在します' });
}
if (error.code === 'P2025') {
return res.status(404).json({ error: 'リソースが見つかりません' });
}
res.status(500).json({
error: 'サーバー内部エラーが発生しました',
...(process.env.NODE_ENV === 'development' && { details: error.message })
});
});
// グレースフルシャットダウン
process.on('SIGINT', async () => {
console.log('アプリケーションをシャットダウンしています...');
const { prisma } = require('./src/lib/prisma');
await prisma.$disconnect();
process.exit(0);
});
// サーバー起動
app.listen(port, () => {
console.log(`🚀 サーバーが起動しました: http://localhost:${port}`);
console.log(`📊 環境: ${process.env.NODE_ENV || 'development'}`);
console.log(`🔗 CORS設定: ${process.env.CLIENT_URL}`);
});
// frontend/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Header from './components/Header';
import Login from './components/Login';
import Register from './components/Register';
import TodoList from './components/TodoList';
import Dashboard from './components/Dashboard';
import './App.css';
// 保護されたルートコンポーネント
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div className="loading">読み込み中...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" />;
};
// ゲストルートコンポーネント
const GuestRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div className="loading">読み込み中...</div>;
}
return !isAuthenticated ? children : <Navigate to="/dashboard" />;
};
function AppContent() {
return (
<div className="app">
<Header />
<main className="main-content">
<Routes>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/login" element={
<GuestRoute>
<Login />
</GuestRoute>
} />
<Route path="/register" element={
<GuestRoute>
<Register />
</GuestRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/todos" element={
<ProtectedRoute>
<TodoList />
</ProtectedRoute>
} />
<Route path="*" element={<div>ページが見つかりません</div>} />
</Routes>
</main>
</div>
);
}
function App() {
return (
<AuthProvider>
<Router>
<AppContent />
</Router>
</AuthProvider>
);
}
export default App;
// frontend/src/components/Dashboard.js
import React from 'react';
import { Link } from 'react-router-dom';
import { useTodos } from '../hooks/useTodos';
import { useAuth } from '../contexts/AuthContext';
const Dashboard = () => {
const { stats, loading, error } = useTodos();
const { user } = useAuth();
if (loading) {
return <div className="loading">読み込み中...</div>;
}
if (error) {
return <div className="error">エラー: {error}</div>;
}
return (
<div className="dashboard">
<div className="dashboard-header">
<h1>ダッシュボード</h1>
<p>ようこそ、{user?.name || user?.email}さん!</p>
</div>
{stats && (
<div className="stats-grid">
<div className="stat-card">
<h3>総タスク数</h3>
<p className="stat-number">{stats.total}</p>
</div>
<div className="stat-card">
<h3>完了済み</h3>
<p className="stat-number">{stats.completed}</p>
</div>
<div className="stat-card">
<h3>未完了</h3>
<p className="stat-number">{stats.pending}</p>
</div>
<div className="stat-card">
<h3>達成率</h3>
<p className="stat-number">{stats.completionRate}%</p>
</div>
</div>
)}
<div className="dashboard-actions">
<Link to="/todos" className="action-button primary">
TODOを管理する
</Link>
<button className="action-button secondary">
統計を見る
</button>
</div>
{stats?.recentActivity && stats.recentActivity.length > 0 && (
<div className="recent-activity">
<h2>最近の活動</h2>
<ul>
{stats.recentActivity.map(activity => (
<li key={activity.id} className="activity-item">
<span className="activity-title">{activity.title}</span>
<span className={`activity-status ${activity.completed ? 'completed' : 'pending'}`}>
{activity.completed ? '完了' : '未完了'}
</span>
<span className="activity-time">
{new Date(activity.updatedAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default Dashboard;
// frontend/src/components/TodoList.js
import React, { useState } from 'react';
import { useTodos } from '../hooks/useTodos';
const TodoList = () => {
const { todos, loading, error, addTodo, toggleTodo, removeTodo, updateTodo } = useTodos();
const [newTodoTitle, setNewTodoTitle] = useState('');
const [filter, setFilter] = useState('all'); // all, active, completed
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
const handleSubmit = async (e) => {
e.preventDefault();
if (!newTodoTitle.trim()) return;
try {
await addTodo(newTodoTitle);
setNewTodoTitle('');
} catch (err) {
// エラー処理はuseTodos内で行われます
}
};
const handleEdit = (todo) => {
setEditingId(todo.id);
setEditTitle(todo.title);
};
const handleSaveEdit = async (id) => {
if (!editTitle.trim()) {
setEditingId(null);
return;
}
try {
await updateTodo(id, { title: editTitle });
setEditingId(null);
} catch (err) {
console.error('編集の保存に失敗しました:', err);
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditTitle('');
};
const handleToggleAll = async () => {
const allCompleted = todos.every(todo => todo.completed);
const updates = todos.map(todo =>
toggleTodo(todo.id, !allCompleted)
);
await Promise.all(updates);
};
if (loading) {
return <div className="loading">読み込み中...</div>;
}
if (error) {
return <div className="error">エラー: {error}</div>;
}
return (
<div className="todo-container">
<div className="todo-header">
<h1>TODO管理</h1>
<div className="todo-controls">
<button
onClick={handleToggleAll}
className="toggle-all-btn"
title="すべてのTODOをトグル"
>
⬌
</button>
<div className="filter-buttons">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
すべて
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => setFilter('active')}
>
未完了
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => setFilter('completed')}
>
完了
</button>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="todo-form">
<input
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="新しいTODOを入力..."
className="todo-input"
/>
<button type="submit" className="add-button">
追加
</button>
</form>
<div className="todo-stats">
<span>表示中: {filteredTodos.length} / 全{todos.length}件</span>
<span>完了: {todos.filter(t => t.completed).length}件</span>
</div>
<ul className="todo-list">
{filteredTodos.map(todo => (
<li key={todo.id} className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
className="todo-checkbox"
/>
{editingId === todo.id ? (
<div className="edit-form">
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="edit-input"
autoFocus
/>
<button
onClick={() => handleSaveEdit(todo.id)}
className="save-btn"
>
保存
</button>
<button
onClick={handleCancelEdit}
className="cancel-btn"
>
取消
</button>
</div>
) : (
<>
<span
className="todo-title"
onDoubleClick={() => handleEdit(todo)}
>
{todo.title}
</span>
<div className="todo-actions">
<button
onClick={() => handleEdit(todo)}
className="edit-button"
title="編集"
>
✏️
</button>
<button
onClick={() => removeTodo(todo.id)}
className="delete-button"
title="削除"
>
🗑️
</button>
</div>
</>
)}
</li>
))}
</ul>
{filteredTodos.length === 0 && (
<div className="empty-state">
{filter === 'all' ? 'TODOがありません' :
filter === 'active' ? '未完了のTODOはありません' :
'完了したTODOはありません'}
</div>
)}
</div>
);
};
export default TodoList;
# バックエンドディレクトリで実行
npx prisma migrate dev --name init
npx prisma generate
cd backend
npm install
npm run dev
cd frontend
npm install
npm start
これで、完全なフルスタックTODOアプリケーションが完成しました。このアプリケーションには、現代のWeb開発に必要なすべての要素が含まれています:
このアプリケーションを基盤として、さらに機能を拡張したり、デプロイを学んだり、より高度な概念を追加していくことができます。お疲れ様でした!