forked from semi-23e/nextjs-todo-tutorial
- 開発環境セットアップセクションを追加(Node.js、VS Code、Git) - 各Phaseに詳細な解説と日本語参考文献を追加 - コード例を充実させ、「なぜそう書くのか」の説明を追加 - よくあるエラーと対処法、FAQ(よくある質問)セクションを追加 - 段階的な学習目標と確認ポイントを明確化 - トラブルシューティングとデバッグ方法を充実 - パフォーマンス最適化とベストプラクティスまで網羅
1775 lines
52 KiB
Markdown
1775 lines
52 KiB
Markdown
# TODOアプリ開発カリキュラム (Next.js + TypeScript)
|
||
|
||
## 概要
|
||
|
||
このカリキュラムは、JavaScript/TypeScript初学者がNext.js 15 (App Router)を使って実践的なTODOアプリを開発できるようになることを目的としています。
|
||
|
||
## 学習目標
|
||
|
||
- React/Next.jsの基本的な概念を理解する
|
||
- TypeScriptの型システムに慣れる
|
||
- 状態管理の基本を学ぶ
|
||
- CRUDアプリケーションの実装パターンを習得する
|
||
- モダンなWeb開発のベストプラクティスを身につける
|
||
|
||
## 前提知識
|
||
|
||
- HTML/CSSの基礎知識
|
||
- JavaScriptの基本文法(変数、関数、配列、オブジェクト)
|
||
- コマンドラインの基本操作
|
||
|
||
## 開発環境セットアップ
|
||
|
||
### 必要なツール
|
||
|
||
1. **Node.js (v18.17以上)**
|
||
- [Node.js公式サイト](https://nodejs.org/ja/)からダウンロード
|
||
- `node --version`で確認
|
||
|
||
2. **Visual Studio Code**
|
||
- [公式サイト](https://code.visualstudio.com/)からダウンロード
|
||
- 推奨拡張機能:
|
||
- TypeScript and JavaScript Language Features
|
||
- React snippets
|
||
- Tailwind CSS IntelliSense
|
||
- Prettier - Code formatter
|
||
- ESLint
|
||
|
||
3. **Git**
|
||
- [Git公式サイト](https://git-scm.com/)からダウンロード
|
||
- `git --version`で確認
|
||
|
||
### プロジェクトの作成
|
||
|
||
```bash
|
||
# プロジェクトの作成
|
||
npx create-next-app@latest my-todo-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
|
||
|
||
# プロジェクトディレクトリに移動
|
||
cd my-todo-app
|
||
|
||
# 開発サーバーの起動
|
||
npm run dev
|
||
```
|
||
|
||
**📚 参考文献:**
|
||
|
||
- [Next.js公式 - Installation](https://nextjs.org/docs/getting-started/installation)
|
||
- [Next.js App Router入門](https://zenn.dev/hayato94087/articles/e9712c4ab7a8f1)
|
||
|
||
## 学習ステップ
|
||
|
||
### Phase 1: 基礎理解 (2-3時間)
|
||
|
||
#### 学習目標
|
||
|
||
- Next.js App Routerの基本構造を理解する
|
||
- ReactコンポーネントとJSXの基本を学ぶ
|
||
- TypeScriptの型定義の基礎を身につける
|
||
|
||
#### Step 1.1: プロジェクト構造の理解
|
||
|
||
**学習内容:**
|
||
|
||
- `src/app/`ディレクトリの役割
|
||
- `page.tsx`と`layout.tsx`の違い
|
||
- TypeScriptファイル(.ts/.tsx)の意味
|
||
|
||
**詳細解説:**
|
||
|
||
```
|
||
src/app/
|
||
├── layout.tsx # 全ページ共通のレイアウト
|
||
├── page.tsx # ホームページ(/)
|
||
├── globals.css # 全体のスタイル
|
||
└── favicon.ico # ファビコン
|
||
```
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [Next.js App Router概要](https://nextjs.org/docs/app)
|
||
- [ファイルベースルーティング解説](https://zenn.dev/frontendflat/articles/nextjs-app-router-file-based-routing)
|
||
|
||
#### Step 1.2: 最初のコンポーネント作成
|
||
|
||
**学習内容:**
|
||
|
||
- Hello Worldコンポーネントの作成
|
||
- JSXの基本文法
|
||
- 型定義の初歩(stringやnumberの使い方)
|
||
|
||
**💡 詳細な実装手順:**
|
||
|
||
1. **新しいコンポーネントファイルを作成**
|
||
|
||
```typescript
|
||
// src/app/components/HelloWorld.tsx
|
||
export default function HelloWorld() {
|
||
return (
|
||
<div className="p-4 bg-blue-100 rounded">
|
||
<h2 className="text-xl font-bold">Hello World!</h2>
|
||
<p className="text-gray-700">Reactコンポーネントの第一歩です</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**解説:**
|
||
|
||
- `export default function` - このコンポーネントを他のファイルから使えるようにする
|
||
- JSXは見た目はHTMLに似ているが、実はJavaScriptの拡張構文
|
||
- `className` - HTMLのclassの代わりに使用(JavaScriptの予約語と競合を避けるため)
|
||
|
||
2. **コンポーネントを使ってみる**
|
||
|
||
```typescript
|
||
// src/app/page.tsx
|
||
import HelloWorld from './components/HelloWorld';
|
||
|
||
export default function Home() {
|
||
return (
|
||
<main className="min-h-screen p-8">
|
||
<h1 className="text-3xl font-bold mb-4">私のTODOアプリ</h1>
|
||
<HelloWorld />
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
**よくあるエラーと対処法:**
|
||
|
||
- `Cannot find module` → importパスを確認(`./components/HelloWorld`)
|
||
- `'HelloWorld' is not defined` → importし忘れていないか確認
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - コンポーネントとプロパティ](https://ja.react.dev/learn/your-first-component)
|
||
- [JSX入門](https://zenn.dev/likr/articles/6be53ca64f29aa035f07)
|
||
|
||
### Phase 2: TODOアプリの骨組み作成 (3-4時間)
|
||
|
||
#### 学習目標
|
||
|
||
- React Hooksの基本(useState)を理解する
|
||
- TypeScriptでの型定義を学ぶ
|
||
- フォームの基本的な操作を身につける
|
||
|
||
#### Step 2.1: UIコンポーネントの作成
|
||
|
||
**学習内容:**
|
||
|
||
- TODOリストの表示部分
|
||
- TODO入力フォーム
|
||
- Tailwind CSSによるスタイリング
|
||
|
||
**💡 詳細な実装手順:**
|
||
|
||
```typescript
|
||
// src/app/page.tsx
|
||
export default function Home() {
|
||
return (
|
||
<main className="min-h-screen p-8 bg-gray-50">
|
||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||
<h1 className="text-2xl font-bold text-gray-800 mb-6">TODOアプリ</h1>
|
||
|
||
{/* 入力フォーム */}
|
||
<div className="mb-4">
|
||
<input
|
||
type="text"
|
||
placeholder="TODOを入力してください"
|
||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button className="w-full mt-2 bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 transition-colors">
|
||
追加
|
||
</button>
|
||
</div>
|
||
|
||
{/* TODOリスト */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center p-3 bg-gray-50 rounded-lg">
|
||
<input type="checkbox" className="mr-3 h-5 w-5" />
|
||
<span className="flex-1">サンプルTODO</span>
|
||
<button className="text-red-500 hover:text-red-700 ml-2">削除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Tailwind CSSのポイント:**
|
||
|
||
- `max-w-md mx-auto` - 幅を制限して中央配置
|
||
- `focus:ring-2 focus:ring-blue-500` - フォーカス時の装飾
|
||
- `hover:bg-blue-600` - ホバー時の背景色変更
|
||
- `space-y-2` - 子要素間の垂直間隔
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [Tailwind CSS公式ドキュメント](https://tailwindcss.com/docs)
|
||
- [Tailwind CSS入門](https://zenn.dev/kagan/books/tailwindcss-tutorial)
|
||
|
||
#### Step 2.2: 状態管理の導入
|
||
|
||
**学習内容:**
|
||
|
||
- `useState`フックの使い方
|
||
- TODOアイテムの型定義
|
||
- 状態とUIの連携
|
||
|
||
**💡 詳細な実装手順:**
|
||
|
||
```typescript
|
||
// src/app/page.tsx
|
||
'use client'; // ← 重要!状態管理を使う時は必須
|
||
|
||
import { useState } from 'react';
|
||
|
||
// TypeScriptの型定義
|
||
type Todo = {
|
||
id: number; // 一意の識別子
|
||
text: string; // TODOの内容
|
||
completed: boolean; // 完了状態
|
||
};
|
||
|
||
export default function Home() {
|
||
// useState フックの使い方
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
const [inputValue, setInputValue] = useState<string>('');
|
||
|
||
return (
|
||
<main className="min-h-screen p-8 bg-gray-50">
|
||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||
<h1 className="text-2xl font-bold text-gray-800 mb-6">TODOアプリ</h1>
|
||
|
||
{/* 入力フォーム */}
|
||
<div className="mb-4">
|
||
<input
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
placeholder="TODOを入力してください"
|
||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button className="w-full mt-2 bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 transition-colors">
|
||
追加
|
||
</button>
|
||
</div>
|
||
|
||
{/* TODOリスト */}
|
||
<div className="space-y-2">
|
||
{todos.length === 0 ? (
|
||
<p className="text-gray-500 text-center py-8">TODOがありません</p>
|
||
) : (
|
||
todos.map((todo) => (
|
||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg">
|
||
<input
|
||
type="checkbox"
|
||
checked={todo.completed}
|
||
className="mr-3 h-5 w-5"
|
||
/>
|
||
<span className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
|
||
{todo.text}
|
||
</span>
|
||
<button className="text-red-500 hover:text-red-700 ml-2">削除</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
**重要なポイント:**
|
||
|
||
- `'use client'` - クライアントサイドで実行するコンポーネントであることを宣言
|
||
- `useState<Todo[]>([])` - 空の配列で初期化、型はTodoの配列
|
||
- `onChange` - 入力が変更されるたびに実行される
|
||
- `map` - 配列の各要素をJSXに変換
|
||
- `key={todo.id}` - Reactが効率的に更新するために必要
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - useState](https://ja.react.dev/reference/react/useState)
|
||
- [TypeScript入門](https://typescriptbook.jp/overview/features)
|
||
|
||
### Phase 3: 機能実装 (4-5時間)
|
||
|
||
#### 学習目標
|
||
|
||
- CRUD操作の基本を理解する
|
||
- イベントハンドラーの実装方法を学ぶ
|
||
- 配列の操作(追加、更新、削除)を身につける
|
||
|
||
#### Step 3.1: TODO追加機能
|
||
|
||
**学習内容:**
|
||
|
||
- フォーム送信処理
|
||
- 新しいTODOの作成
|
||
- リストへの反映
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// TODO追加関数の実装
|
||
const addTodo = () => {
|
||
// 空の入力をチェック(バリデーション)
|
||
if (inputValue.trim() === '') {
|
||
alert('TODOを入力してください');
|
||
return;
|
||
}
|
||
|
||
// 新しいTODOオブジェクトを作成
|
||
const newTodo: Todo = {
|
||
id: Date.now(), // 簡易的なID生成(本番ではuuidを推奨)
|
||
text: inputValue,
|
||
completed: false
|
||
};
|
||
|
||
// 状態を更新(重要:配列をコピーして新しい配列を作る)
|
||
setTodos([...todos, newTodo]);
|
||
|
||
// 入力欄をクリア
|
||
setInputValue('');
|
||
};
|
||
|
||
// フォームで使う場合
|
||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault(); // ページのリロードを防ぐ
|
||
addTodo();
|
||
};
|
||
|
||
// JSX部分
|
||
<form onSubmit={handleSubmit} className="mb-4">
|
||
<input
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
placeholder="TODOを入力してEnterキー"
|
||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="w-full mt-2 bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 transition-colors"
|
||
>
|
||
追加
|
||
</button>
|
||
</form>
|
||
```
|
||
|
||
**なぜこのように書くのか:**
|
||
|
||
- `trim()` - 空白だけの入力を防ぐ
|
||
- `Date.now()` - ミリ秒単位のタイムスタンプでIDを生成(簡易版)
|
||
- `[...todos, newTodo]` - スプレッド構文で既存の配列をコピーして新しい要素を追加
|
||
- `e.preventDefault()` - formのデフォルト動作(ページリロード)を止める
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - フォームの処理](https://ja.react.dev/reference/react-dom/components/form)
|
||
- [JavaScript Array メソッド入門](https://zenn.dev/antez/articles/array-methods-cheat-sheet)
|
||
|
||
#### Step 3.2: TODO完了機能
|
||
|
||
**学習内容:**
|
||
|
||
- チェックボックスの実装
|
||
- 完了状態の切り替え
|
||
- 条件付きスタイリング
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// 完了状態を切り替える関数
|
||
const toggleTodo = (id: number) => {
|
||
// mapを使って新しい配列を作成(イミュータビリティ)
|
||
const updatedTodos = todos.map(todo => {
|
||
if (todo.id === id) {
|
||
// 該当するTODOを見つけたら、completedを反転
|
||
return { ...todo, completed: !todo.completed };
|
||
}
|
||
return todo; // それ以外はそのまま
|
||
});
|
||
|
||
setTodos(updatedTodos);
|
||
};
|
||
|
||
// TODOリストの表示部分
|
||
<div className="space-y-2">
|
||
{todos.map((todo) => (
|
||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
checked={todo.completed}
|
||
onChange={() => toggleTodo(todo.id)}
|
||
className="mr-3 h-5 w-5 cursor-pointer"
|
||
/>
|
||
<span
|
||
className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}
|
||
>
|
||
{todo.text}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
```
|
||
|
||
**解説:**
|
||
|
||
- `map` - 配列の各要素を変換して新しい配列を作る
|
||
- `{ ...todo, completed: !todo.completed }` - スプレッド構文でtodoをコピーし、completedだけ上書き
|
||
- `checked={todo.completed}` - チェックボックスの状態をデータと同期
|
||
- `line-through` - 完了時に打ち消し線を表示(Tailwind CSSのクラス)
|
||
|
||
**よくあるミス:**
|
||
|
||
```typescript
|
||
// ❌ これは動かない!直接配列を変更している
|
||
const todo = todos.find(t => t.id === id);
|
||
if (todo) {
|
||
todo.completed = !todo.completed; // Reactが変更を検知できない
|
||
setTodos(todos); // 同じ配列をセットしても再レンダリングされない
|
||
}
|
||
```
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - 状態の更新](https://ja.react.dev/learn/updating-objects-in-state)
|
||
- [イミュータビリティの重要性](https://zenn.dev/okuoku/articles/react-immutability)
|
||
|
||
#### Step 3.3: TODO削除機能
|
||
|
||
**学習内容:**
|
||
|
||
- 削除ボタンの追加
|
||
- 配列からの要素削除
|
||
- 確認ダイアログ(オプション)
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// 削除関数
|
||
const deleteTodo = (id: number) => {
|
||
// 確認ダイアログを表示(オプション)
|
||
if (confirm('本当に削除しますか?')) {
|
||
// filterを使って該当ID以外の要素で新しい配列を作成
|
||
const filteredTodos = todos.filter(todo => todo.id !== id);
|
||
setTodos(filteredTodos);
|
||
}
|
||
};
|
||
|
||
// TODOリストに削除ボタンを追加
|
||
<div className="space-y-2">
|
||
{todos.map((todo) => (
|
||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
checked={todo.completed}
|
||
onChange={() => toggleTodo(todo.id)}
|
||
className="mr-3 h-5 w-5 cursor-pointer"
|
||
/>
|
||
<span
|
||
className={`flex-1 ${todo.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}
|
||
>
|
||
{todo.text}
|
||
</span>
|
||
<button
|
||
onClick={() => deleteTodo(todo.id)}
|
||
className="text-red-500 hover:text-red-700 ml-2 px-2 py-1 rounded transition-colors"
|
||
>
|
||
削除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
```
|
||
|
||
**解説:**
|
||
|
||
- `filter` - 条件に合う要素だけで新しい配列を作成
|
||
- `todo.id !== id` - 削除対象以外の要素を残す
|
||
- `confirm()` - ブラウザの確認ダイアログ(true/falseを返す)
|
||
|
||
**カスタム確認ダイアログの例:**
|
||
|
||
```typescript
|
||
// 状態でモーダルを管理
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||
|
||
const handleDeleteClick = (id: number) => {
|
||
setDeletingId(id);
|
||
setShowDeleteModal(true);
|
||
};
|
||
|
||
const confirmDelete = () => {
|
||
if (deletingId) {
|
||
setTodos(todos.filter(todo => todo.id !== deletingId));
|
||
}
|
||
setShowDeleteModal(false);
|
||
setDeletingId(null);
|
||
};
|
||
|
||
// モーダルコンポーネント
|
||
{showDeleteModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white p-6 rounded-lg shadow-lg">
|
||
<p className="mb-4 text-gray-800">本当に削除しますか?</p>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={confirmDelete}
|
||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors"
|
||
>
|
||
削除
|
||
</button>
|
||
<button
|
||
onClick={() => setShowDeleteModal(false)}
|
||
className="bg-gray-300 text-gray-800 px-4 py-2 rounded hover:bg-gray-400 transition-colors"
|
||
>
|
||
キャンセル
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [JavaScript Array.filter()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter)
|
||
- [React Modal実装パターン](https://zenn.dev/yukikoma/articles/react-modal-patterns)
|
||
|
||
### Phase 4: 高度な機能 (3-4時間)
|
||
|
||
#### 学習目標
|
||
|
||
- 複雑な状態管理パターンを理解する
|
||
- インライン編集の実装方法を学ぶ
|
||
- データの永続化について理解する
|
||
|
||
#### Step 4.1: TODO編集機能
|
||
|
||
**学習内容:**
|
||
|
||
- インライン編集の実装
|
||
- 保存とキャンセル処理
|
||
- 複数状態の管理
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// 編集用の状態管理
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [editText, setEditText] = useState('');
|
||
|
||
// 編集開始関数
|
||
const startEdit = (todo: Todo) => {
|
||
setEditingId(todo.id);
|
||
setEditText(todo.text);
|
||
};
|
||
|
||
// 編集保存関数
|
||
const saveEdit = () => {
|
||
if (editText.trim() === '') {
|
||
alert('TODOが空です');
|
||
return;
|
||
}
|
||
|
||
const updatedTodos = todos.map(todo => {
|
||
if (todo.id === editingId) {
|
||
return { ...todo, text: editText };
|
||
}
|
||
return todo;
|
||
});
|
||
|
||
setTodos(updatedTodos);
|
||
setEditingId(null); // 編集モードを終了
|
||
setEditText('');
|
||
};
|
||
|
||
// 編集キャンセル関数
|
||
const cancelEdit = () => {
|
||
setEditingId(null);
|
||
setEditText('');
|
||
};
|
||
|
||
// TODOリストの表示部分
|
||
<div className="space-y-2">
|
||
{todos.map((todo) => (
|
||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
checked={todo.completed}
|
||
onChange={() => toggleTodo(todo.id)}
|
||
className="mr-3 h-5 w-5 cursor-pointer"
|
||
/>
|
||
|
||
{editingId === todo.id ? (
|
||
// 編集モード
|
||
<input
|
||
type="text"
|
||
value={editText}
|
||
onChange={(e) => setEditText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') saveEdit();
|
||
if (e.key === 'Escape') cancelEdit();
|
||
}}
|
||
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
// 通常モード
|
||
<span
|
||
onClick={() => startEdit(todo)}
|
||
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
|
||
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
|
||
}`}
|
||
>
|
||
{todo.text}
|
||
</span>
|
||
)}
|
||
|
||
{editingId === todo.id ? (
|
||
// 編集モードのボタン
|
||
<div className="flex gap-2 ml-2">
|
||
<button
|
||
onClick={saveEdit}
|
||
className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors"
|
||
>
|
||
保存
|
||
</button>
|
||
<button
|
||
onClick={cancelEdit}
|
||
className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors"
|
||
>
|
||
キャンセル
|
||
</button>
|
||
</div>
|
||
) : (
|
||
// 通常モードのボタン
|
||
<div className="flex gap-2 ml-2">
|
||
<button
|
||
onClick={() => startEdit(todo)}
|
||
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
|
||
>
|
||
編集
|
||
</button>
|
||
<button
|
||
onClick={() => deleteTodo(todo.id)}
|
||
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
|
||
>
|
||
削除
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
```
|
||
|
||
**ポイント:**
|
||
|
||
- `editingId` - 現在編集中のTODOのIDを保持
|
||
- `onKeyDown` - キーボードイベントでEnter/Escを検知
|
||
- `autoFocus` - 編集開始時に自動的にフォーカス
|
||
- 三項演算子 `? :` - 条件によって表示を切り替え
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - 条件付きレンダリング](https://ja.react.dev/learn/conditional-rendering)
|
||
- [キーボードイベントの処理](https://zenn.dev/yoichi_dev/articles/react-keyboard-events)
|
||
|
||
#### Step 4.2: フィルタリング機能
|
||
|
||
**学習内容:**
|
||
|
||
- All/Active/Completedの切り替え
|
||
- 条件付きレンダリング
|
||
- 状態による表示制御
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// フィルタリング用の型定義
|
||
type FilterType = 'all' | 'active' | 'completed';
|
||
|
||
// フィルタリング状態の管理
|
||
const [filter, setFilter] = useState<FilterType>('all');
|
||
|
||
// フィルタリング関数
|
||
const getFilteredTodos = () => {
|
||
switch (filter) {
|
||
case 'active':
|
||
return todos.filter(todo => !todo.completed);
|
||
case 'completed':
|
||
return todos.filter(todo => todo.completed);
|
||
default:
|
||
return todos;
|
||
}
|
||
};
|
||
|
||
// 統計情報の計算
|
||
const activeCount = todos.filter(todo => !todo.completed).length;
|
||
const completedCount = todos.filter(todo => todo.completed).length;
|
||
|
||
// フィルタリングボタンの実装
|
||
<div className="flex justify-between items-center mb-4 p-4 bg-gray-100 rounded-lg">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setFilter('all')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'all'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
すべて ({todos.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setFilter('active')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'active'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
未完了 ({activeCount})
|
||
</button>
|
||
<button
|
||
onClick={() => setFilter('completed')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'completed'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
完了 ({completedCount})
|
||
</button>
|
||
</div>
|
||
|
||
{/* 完了済みTODOをすべて削除 */}
|
||
{completedCount > 0 && (
|
||
<button
|
||
onClick={() => setTodos(todos.filter(todo => !todo.completed))}
|
||
className="text-red-500 hover:text-red-700 px-3 py-1 rounded transition-colors"
|
||
>
|
||
完了済みを削除
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
// フィルタリング結果の表示
|
||
<div className="space-y-2">
|
||
{getFilteredTodos().length === 0 ? (
|
||
<p className="text-gray-500 text-center py-8">
|
||
{filter === 'active' && 'すべて完了しました!'}
|
||
{filter === 'completed' && '完了したTODOがありません'}
|
||
{filter === 'all' && 'TODOがありません'}
|
||
</p>
|
||
) : (
|
||
getFilteredTodos().map((todo) => (
|
||
// 通常のTODOアイテム表示
|
||
<div key={todo.id} className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||
{/* 省略:通常のTODOアイテム */}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
```
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React State管理パターン](https://zenn.dev/uhyo/articles/react-state-management-2022)
|
||
- [条件付きレンダリングのベストプラクティス](https://zenn.dev/t_keshi/articles/react-conditional-rendering)
|
||
|
||
#### Step 4.3: データの永続化
|
||
|
||
**学習内容:**
|
||
|
||
- Local Storageの活用
|
||
- `useEffect`フックの理解
|
||
- データの保存と読み込み
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
import { useState, useEffect } from 'react';
|
||
|
||
// Local Storage のキー
|
||
const STORAGE_KEY = 'todo-app-data';
|
||
|
||
export default function Home() {
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
const [inputValue, setInputValue] = useState('');
|
||
// 他の状態...
|
||
|
||
// アプリ起動時にデータを読み込み
|
||
useEffect(() => {
|
||
const savedTodos = localStorage.getItem(STORAGE_KEY);
|
||
if (savedTodos) {
|
||
try {
|
||
const parsedTodos = JSON.parse(savedTodos);
|
||
setTodos(parsedTodos);
|
||
} catch (error) {
|
||
console.error('データの読み込みに失敗しました:', error);
|
||
}
|
||
}
|
||
}, []); // 空の依存配列で初回のみ実行
|
||
|
||
// TODOが変更されるたびに保存
|
||
useEffect(() => {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
||
}, [todos]); // todosが変更されるたびに実行
|
||
|
||
// カスタムフックとして分離する場合
|
||
const useTodoStorage = (key: string) => {
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
|
||
// 初期データの読み込み
|
||
useEffect(() => {
|
||
const savedData = localStorage.getItem(key);
|
||
if (savedData) {
|
||
try {
|
||
setTodos(JSON.parse(savedData));
|
||
} catch (error) {
|
||
console.error('データの読み込みエラー:', error);
|
||
}
|
||
}
|
||
}, [key]);
|
||
|
||
// データの保存
|
||
useEffect(() => {
|
||
localStorage.setItem(key, JSON.stringify(todos));
|
||
}, [todos, key]);
|
||
|
||
return [todos, setTodos] as const;
|
||
};
|
||
|
||
// 使用例
|
||
// const [todos, setTodos] = useTodoStorage('my-todos');
|
||
|
||
// データのエクスポート機能
|
||
const exportData = () => {
|
||
const dataStr = JSON.stringify(todos, null, 2);
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'todos.json';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// データのインポート機能
|
||
const importData = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const importedTodos = JSON.parse(e.target?.result as string);
|
||
setTodos(importedTodos);
|
||
} catch (error) {
|
||
alert('ファイルの読み込みに失敗しました');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<main className="min-h-screen p-8 bg-gray-50">
|
||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||
{/* ヘッダー部分 */}
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h1 className="text-2xl font-bold text-gray-800">TODOアプリ</h1>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={exportData}
|
||
className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors"
|
||
>
|
||
エクスポート
|
||
</button>
|
||
<label className="text-blue-500 hover:text-blue-700 px-3 py-1 rounded transition-colors cursor-pointer">
|
||
インポート
|
||
<input
|
||
type="file"
|
||
accept=".json"
|
||
onChange={importData}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 残りのコンポーネント */}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
**useEffectのポイント:**
|
||
|
||
- 第2引数の依存配列が重要
|
||
- `[]` - 初回のみ実行(コンポーネントマウント時)
|
||
- `[todos]` - todosが変更されるたびに実行
|
||
- cleanup関数が必要な場合は return で関数を返す
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - useEffect](https://ja.react.dev/reference/react/useEffect)
|
||
- [Local Storage活用術](https://zenn.dev/oubakiou/articles/localstorage-tips)
|
||
|
||
### Phase 5: 仕上げとベストプラクティス (2-3時間)
|
||
|
||
#### 学習目標
|
||
|
||
- コンポーネントの分割と再利用性を理解する
|
||
- パフォーマンス最適化の基本を学ぶ
|
||
- エラーハンドリングの実装方法を身につける
|
||
|
||
#### Step 5.1: コンポーネントの分割
|
||
|
||
**学習内容:**
|
||
|
||
- 再利用可能なコンポーネント化
|
||
- propsの型定義
|
||
- 関心の分離
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// src/app/components/TodoItem.tsx
|
||
interface TodoItemProps {
|
||
todo: Todo;
|
||
onToggle: (id: number) => void;
|
||
onDelete: (id: number) => void;
|
||
onEdit: (id: number, text: string) => void;
|
||
}
|
||
|
||
export function TodoItem({ todo, onToggle, onDelete, onEdit }: TodoItemProps) {
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [editText, setEditText] = useState(todo.text);
|
||
|
||
const handleSave = () => {
|
||
if (editText.trim() === '') {
|
||
alert('TODOが空です');
|
||
return;
|
||
}
|
||
onEdit(todo.id, editText);
|
||
setIsEditing(false);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setEditText(todo.text);
|
||
setIsEditing(false);
|
||
};
|
||
|
||
return (
|
||
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||
<input
|
||
type="checkbox"
|
||
checked={todo.completed}
|
||
onChange={() => onToggle(todo.id)}
|
||
className="mr-3 h-5 w-5 cursor-pointer"
|
||
/>
|
||
|
||
{isEditing ? (
|
||
<input
|
||
type="text"
|
||
value={editText}
|
||
onChange={(e) => setEditText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleSave();
|
||
if (e.key === 'Escape') handleCancel();
|
||
}}
|
||
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => setIsEditing(true)}
|
||
className={`flex-1 cursor-pointer p-2 rounded hover:bg-gray-200 transition-colors ${
|
||
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
|
||
}`}
|
||
>
|
||
{todo.text}
|
||
</span>
|
||
)}
|
||
|
||
{isEditing ? (
|
||
<div className="flex gap-2 ml-2">
|
||
<button onClick={handleSave} className="text-green-600 hover:text-green-800 px-2 py-1 rounded transition-colors">
|
||
保存
|
||
</button>
|
||
<button onClick={handleCancel} className="text-gray-600 hover:text-gray-800 px-2 py-1 rounded transition-colors">
|
||
キャンセル
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-2 ml-2">
|
||
<button
|
||
onClick={() => setIsEditing(true)}
|
||
className="text-blue-500 hover:text-blue-700 px-2 py-1 rounded transition-colors"
|
||
>
|
||
編集
|
||
</button>
|
||
<button
|
||
onClick={() => onDelete(todo.id)}
|
||
className="text-red-500 hover:text-red-700 px-2 py-1 rounded transition-colors"
|
||
>
|
||
削除
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// src/app/components/TodoList.tsx
|
||
interface TodoListProps {
|
||
todos: Todo[];
|
||
onToggle: (id: number) => void;
|
||
onDelete: (id: number) => void;
|
||
onEdit: (id: number, text: string) => void;
|
||
}
|
||
|
||
export function TodoList({ todos, onToggle, onDelete, onEdit }: TodoListProps) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{todos.length === 0 ? (
|
||
<p className="text-gray-500 text-center py-8">TODOがありません</p>
|
||
) : (
|
||
todos.map((todo) => (
|
||
<TodoItem
|
||
key={todo.id}
|
||
todo={todo}
|
||
onToggle={onToggle}
|
||
onDelete={onDelete}
|
||
onEdit={onEdit}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// src/app/components/AddTodoForm.tsx
|
||
interface AddTodoFormProps {
|
||
onAdd: (text: string) => void;
|
||
}
|
||
|
||
export function AddTodoForm({ onAdd }: AddTodoFormProps) {
|
||
const [inputValue, setInputValue] = useState('');
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (inputValue.trim() === '') {
|
||
alert('TODOを入力してください');
|
||
return;
|
||
}
|
||
onAdd(inputValue);
|
||
setInputValue('');
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="mb-4">
|
||
<input
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
placeholder="TODOを入力してください"
|
||
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="w-full mt-2 bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 transition-colors"
|
||
>
|
||
追加
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
// メインコンポーネント
|
||
import { AddTodoForm } from './components/AddTodoForm';
|
||
import { TodoList } from './components/TodoList';
|
||
|
||
export default function Home() {
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
|
||
const addTodo = (text: string) => {
|
||
const newTodo: Todo = {
|
||
id: Date.now(),
|
||
text,
|
||
completed: false
|
||
};
|
||
setTodos([...todos, newTodo]);
|
||
};
|
||
|
||
const toggleTodo = (id: number) => {
|
||
setTodos(todos.map(todo =>
|
||
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||
));
|
||
};
|
||
|
||
const deleteTodo = (id: number) => {
|
||
setTodos(todos.filter(todo => todo.id !== id));
|
||
};
|
||
|
||
const editTodo = (id: number, text: string) => {
|
||
setTodos(todos.map(todo =>
|
||
todo.id === id ? { ...todo, text } : todo
|
||
));
|
||
};
|
||
|
||
return (
|
||
<main className="min-h-screen p-8 bg-gray-50">
|
||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||
<h1 className="text-2xl font-bold text-gray-800 mb-6">TODOアプリ</h1>
|
||
<AddTodoForm onAdd={addTodo} />
|
||
<TodoList
|
||
todos={todos}
|
||
onToggle={toggleTodo}
|
||
onDelete={deleteTodo}
|
||
onEdit={editTodo}
|
||
/>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
**コンポーネント分割のメリット:**
|
||
|
||
- 単一責任の原則に従う
|
||
- 再利用性が高まる
|
||
- テストが書きやすくなる
|
||
- コードが読みやすくなる
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - コンポーネントの分割](https://ja.react.dev/learn/thinking-in-react)
|
||
- [コンポーネント設計のベストプラクティス](https://zenn.dev/yoshiko/articles/99f8047555f700)
|
||
|
||
#### Step 5.2: エラーハンドリング
|
||
|
||
**学習内容:**
|
||
|
||
- 入力検証の強化
|
||
- エラーメッセージの表示
|
||
- エラーバウンダリーの実装
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
// src/app/components/ErrorBoundary.tsx
|
||
'use client';
|
||
|
||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||
|
||
interface Props {
|
||
children: ReactNode;
|
||
}
|
||
|
||
interface State {
|
||
hasError: boolean;
|
||
error?: Error;
|
||
}
|
||
|
||
export class ErrorBoundary extends Component<Props, State> {
|
||
public state: State = {
|
||
hasError: false
|
||
};
|
||
|
||
public static getDerivedStateFromError(error: Error): State {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||
console.error('エラーが発生しました:', error, errorInfo);
|
||
}
|
||
|
||
public render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||
<div className="bg-white p-8 rounded-lg shadow-md max-w-md">
|
||
<h2 className="text-2xl font-bold text-red-600 mb-4">エラーが発生しました</h2>
|
||
<p className="text-gray-700 mb-4">
|
||
申し訳ございません。予期しないエラーが発生しました。
|
||
</p>
|
||
<button
|
||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
|
||
>
|
||
再試行
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
// カスタムフックでエラー処理
|
||
const useErrorHandler = () => {
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const handleError = (errorMessage: string) => {
|
||
setError(errorMessage);
|
||
setTimeout(() => setError(null), 5000); // 5秒後に自動で消す
|
||
};
|
||
|
||
const clearError = () => setError(null);
|
||
|
||
return { error, handleError, clearError };
|
||
};
|
||
|
||
// エラーメッセージコンポーネント
|
||
const ErrorMessage = ({ message, onClose }: { message: string; onClose: () => void }) => (
|
||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg flex justify-between items-center">
|
||
<span>{message}</span>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-red-500 hover:text-red-700 font-bold"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
// 入力検証の強化
|
||
const validateTodo = (text: string): string | null => {
|
||
if (text.trim() === '') {
|
||
return 'TODOを入力してください';
|
||
}
|
||
if (text.length > 100) {
|
||
return 'TODOは100文字以内で入力してください';
|
||
}
|
||
if (text.trim().length < 2) {
|
||
return 'TODOは2文字以上で入力してください';
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 使用例
|
||
export function AddTodoForm({ onAdd }: AddTodoFormProps) {
|
||
const [inputValue, setInputValue] = useState('');
|
||
const { error, handleError, clearError } = useErrorHandler();
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
clearError();
|
||
|
||
const validationError = validateTodo(inputValue);
|
||
if (validationError) {
|
||
handleError(validationError);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
onAdd(inputValue);
|
||
setInputValue('');
|
||
} catch (err) {
|
||
handleError('TODOの追加に失敗しました');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mb-4">
|
||
{error && <ErrorMessage message={error} onClose={clearError} />}
|
||
<form onSubmit={handleSubmit}>
|
||
<input
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
placeholder="TODOを入力してください"
|
||
className={`w-full p-3 border rounded-lg focus:outline-none focus:ring-2 ${
|
||
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
|
||
}`}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="w-full mt-2 bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50"
|
||
disabled={inputValue.trim() === ''}
|
||
>
|
||
追加
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - エラーバウンダリー](https://ja.react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
|
||
- [React エラーハンドリング完全ガイド](https://zenn.dev/brachio_takumi/articles/react-error-handling)
|
||
|
||
#### Step 5.3: パフォーマンス最適化
|
||
|
||
**学習内容:**
|
||
|
||
- `useCallback`の基礎
|
||
- `useMemo`の基礎
|
||
- キー属性の重要性
|
||
|
||
**💡 詳細な実装と解説:**
|
||
|
||
```typescript
|
||
import { useState, useCallback, useMemo } from 'react';
|
||
|
||
export default function Home() {
|
||
const [todos, setTodos] = useState<Todo[]>([]);
|
||
const [filter, setFilter] = useState<FilterType>('all');
|
||
|
||
// useCallbackでコールバック関数をメモ化
|
||
const addTodo = useCallback((text: string) => {
|
||
const newTodo: Todo = {
|
||
id: Date.now(),
|
||
text,
|
||
completed: false
|
||
};
|
||
setTodos(prev => [...prev, newTodo]);
|
||
}, []);
|
||
|
||
const toggleTodo = useCallback((id: number) => {
|
||
setTodos(prev => prev.map(todo =>
|
||
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||
));
|
||
}, []);
|
||
|
||
const deleteTodo = useCallback((id: number) => {
|
||
setTodos(prev => prev.filter(todo => todo.id !== id));
|
||
}, []);
|
||
|
||
const editTodo = useCallback((id: number, text: string) => {
|
||
setTodos(prev => prev.map(todo =>
|
||
todo.id === id ? { ...todo, text } : todo
|
||
));
|
||
}, []);
|
||
|
||
// useMemoで計算結果をメモ化
|
||
const filteredTodos = useMemo(() => {
|
||
switch (filter) {
|
||
case 'active':
|
||
return todos.filter(todo => !todo.completed);
|
||
case 'completed':
|
||
return todos.filter(todo => todo.completed);
|
||
default:
|
||
return todos;
|
||
}
|
||
}, [todos, filter]);
|
||
|
||
const stats = useMemo(() => ({
|
||
total: todos.length,
|
||
active: todos.filter(todo => !todo.completed).length,
|
||
completed: todos.filter(todo => todo.completed).length
|
||
}), [todos]);
|
||
|
||
return (
|
||
<main className="min-h-screen p-8 bg-gray-50">
|
||
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
|
||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||
TODOアプリ ({stats.total})
|
||
</h1>
|
||
|
||
<AddTodoForm onAdd={addTodo} />
|
||
|
||
<FilterButtons
|
||
filter={filter}
|
||
onFilterChange={setFilter}
|
||
stats={stats}
|
||
/>
|
||
|
||
<TodoList
|
||
todos={filteredTodos}
|
||
onToggle={toggleTodo}
|
||
onDelete={deleteTodo}
|
||
onEdit={editTodo}
|
||
/>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
// React.memoでコンポーネントをメモ化
|
||
const FilterButtons = React.memo(({ filter, onFilterChange, stats }: {
|
||
filter: FilterType;
|
||
onFilterChange: (filter: FilterType) => void;
|
||
stats: { total: number; active: number; completed: number };
|
||
}) => {
|
||
return (
|
||
<div className="flex gap-2 mb-4">
|
||
<button
|
||
onClick={() => onFilterChange('all')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'all'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-200 hover:bg-gray-300'
|
||
}`}
|
||
>
|
||
すべて ({stats.total})
|
||
</button>
|
||
<button
|
||
onClick={() => onFilterChange('active')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'active'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-200 hover:bg-gray-300'
|
||
}`}
|
||
>
|
||
未完了 ({stats.active})
|
||
</button>
|
||
<button
|
||
onClick={() => onFilterChange('completed')}
|
||
className={`px-4 py-2 rounded transition-colors ${
|
||
filter === 'completed'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-200 hover:bg-gray-300'
|
||
}`}
|
||
>
|
||
完了 ({stats.completed})
|
||
</button>
|
||
</div>
|
||
);
|
||
});
|
||
```
|
||
|
||
**パフォーマンス最適化のポイント:**
|
||
|
||
- `useCallback` - 関数の再生成を防ぐ
|
||
- `useMemo` - 計算結果をキャッシュ
|
||
- `React.memo` - 不要な再レンダリングを防ぐ
|
||
- 適切なkey属性の使用
|
||
|
||
**📚 このステップの参考文献:**
|
||
|
||
- [React公式 - パフォーマンス最適化](https://ja.react.dev/learn/render-and-commit)
|
||
- [React Hooks パフォーマンスガイド](https://zenn.dev/uhyo/articles/react-hooks-performance)
|
||
|
||
## つまずきやすいポイントと対策
|
||
|
||
### 1. TypeScriptのエラー
|
||
|
||
**よくあるエラーと解決策:**
|
||
|
||
```typescript
|
||
// エラー例1: Parameter 'e' implicitly has an 'any' type
|
||
// ❌ エラーが出るコード
|
||
const handleChange = (e) => {
|
||
setInputValue(e.target.value);
|
||
};
|
||
|
||
// ✅ 解決策1: 型を明示的に指定
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setInputValue(e.target.value);
|
||
};
|
||
|
||
// ✅ 解決策2: インラインで書く(型推論が効く)
|
||
<input onChange={(e) => setInputValue(e.target.value)} />
|
||
|
||
// エラー例2: Object is possibly 'undefined'
|
||
// ❌ エラーが出るコード
|
||
const todo = todos.find(t => t.id === id);
|
||
console.log(todo.text); // todoがundefinedの可能性
|
||
|
||
// ✅ 解決策: オプショナルチェイニング
|
||
console.log(todo?.text);
|
||
|
||
// または条件分岐
|
||
if (todo) {
|
||
console.log(todo.text);
|
||
}
|
||
```
|
||
|
||
**VSCodeの便利機能:**
|
||
|
||
- 変数にカーソルを合わせると型が表示される
|
||
- Ctrl+Space で補完候補を表示
|
||
- エラーの上にカーソルを置くと「Quick Fix」が提案される
|
||
|
||
### 2. 状態更新のイミュータビリティ
|
||
|
||
**よくある間違いと正しい書き方:**
|
||
|
||
```typescript
|
||
// ❌ NGパターン
|
||
todos.push(newTodo); // 配列を直接変更
|
||
todos[0].completed = true; // オブジェクトを直接変更
|
||
|
||
// ✅ OKパターン
|
||
setTodos([...todos, newTodo]); // 新しい配列を作成
|
||
setTodos(todos.map(todo =>
|
||
todo.id === id ? { ...todo, completed: true } : todo
|
||
));
|
||
```
|
||
|
||
### 3. useEffectの依存配列
|
||
|
||
**よくある間違い:**
|
||
|
||
```typescript
|
||
// ❌ 依存配列を忘れる(無限ループの原因)
|
||
useEffect(() => {
|
||
setTodos(todos.filter(todo => todo.completed));
|
||
}); // 依存配列がない
|
||
|
||
// ✅ 正しい書き方
|
||
useEffect(() => {
|
||
// 何らかの処理
|
||
}, [todos]); // 依存配列を指定
|
||
```
|
||
|
||
### 4. イベントハンドラーの型
|
||
|
||
**正しい型定義:**
|
||
|
||
```typescript
|
||
// フォーム送信
|
||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
// 処理
|
||
};
|
||
|
||
// 入力変更
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setInputValue(e.target.value);
|
||
};
|
||
|
||
// ボタンクリック
|
||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||
// 処理
|
||
};
|
||
```
|
||
|
||
## 評価基準
|
||
|
||
### 基本機能 (必須)
|
||
|
||
- [ ] TODOの追加ができる
|
||
- [ ] TODOの完了/未完了の切り替えができる
|
||
- [ ] TODOの削除ができる
|
||
- [ ] 適切な型定義がされている
|
||
- [ ] エラーハンドリングが実装されている
|
||
|
||
### 追加機能 (推奨)
|
||
|
||
- [ ] TODOの編集ができる
|
||
- [ ] フィルタリング機能がある
|
||
- [ ] データが永続化される
|
||
- [ ] レスポンシブデザインに対応
|
||
- [ ] コンポーネントが適切に分割されている
|
||
|
||
### コード品質
|
||
|
||
- [ ] TypeScriptの型安全性が確保されている
|
||
- [ ] コードが読みやすく保守性が高い
|
||
- [ ] 適切なエラーハンドリングが実装されている
|
||
- [ ] パフォーマンスが考慮されている
|
||
|
||
## 発展課題
|
||
|
||
1. **ドラッグ&ドロップ**での並び替え
|
||
- react-beautiful-dndライブラリの使用
|
||
- カスタムフックでの実装
|
||
|
||
2. **カテゴリー機能**の追加
|
||
- TODOのカテゴリ分類
|
||
- カテゴリー別フィルタリング
|
||
|
||
3. **期限設定**機能
|
||
- 期限日の設定
|
||
- 期限切れの警告表示
|
||
|
||
4. **Next.js API Routes**を使ったバックエンド実装
|
||
- データベースとの連携
|
||
- RESTful APIの実装
|
||
|
||
5. **認証機能**の追加(NextAuth.js)
|
||
- ユーザー登録・ログイン
|
||
- ユーザー別TODOの管理
|
||
|
||
6. **PWA(Progressive Web App)対応**
|
||
- オフライン対応
|
||
- プッシュ通知
|
||
|
||
7. **テストの実装**
|
||
- Jest + React Testing Library
|
||
- E2Eテスト(Playwright)
|
||
|
||
## 推奨リソース
|
||
|
||
### 📚 公式ドキュメント
|
||
|
||
- [Next.js公式ドキュメント](https://nextjs.org/docs)
|
||
- [React公式ドキュメント](https://ja.react.dev/)
|
||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
||
- [Tailwind CSS公式ドキュメント](https://tailwindcss.com/docs)
|
||
|
||
### 🎥 日本語の動画教材
|
||
|
||
- [【2024年最新】React完全入門ガイド|Hooks、Next.js、TypeScriptまで](https://www.youtube.com/watch?v=TGiCr7S2E7E)
|
||
- [Next.js入門 - App Routerを基礎から学ぶ](https://www.youtube.com/watch?v=BVq8n8tVNJg)
|
||
- [TypeScript入門 完全版](https://www.youtube.com/watch?v=F9vzRz6jyRk)
|
||
- [React Hooks完全入門](https://www.youtube.com/watch?v=xkxZWFVU2MA)
|
||
|
||
### 📖 日本語の参考記事・書籍
|
||
|
||
- [サバイバルTypeScript](https://typescriptbook.jp/) - TypeScriptの実践的な解説
|
||
- [React入門 TODOアプリ開発](https://zenn.dev/topics/react-todo) - Zennの関連記事集
|
||
- [Next.js App Router入門](https://zenn.dev/hayato94087/articles/e9712c4ab7a8f1) - 詳細な解説記事
|
||
- [モダンJavaScript入門](https://zenn.dev/antez/books/modern-javascript-1) - JavaScript基礎の復習
|
||
- 書籍:「React実践入門」(技術評論社)
|
||
- 書籍:「TypeScript実践プログラミング」(マイナビ出版)
|
||
|
||
### 🛠️ 実践的な学習リソース
|
||
|
||
- [React Tutorial: Tic-Tac-Toe(日本語版)](https://ja.react.dev/learn/tutorial-tic-tac-toe) - 公式チュートリアル
|
||
- [TypeScript Playground](https://www.typescriptlang.org/play) - ブラウザで試せる
|
||
- [StackBlitz](https://stackblitz.com/) - オンラインでNext.jsを試せる
|
||
- [CodeSandbox](https://codesandbox.io/) - オンライン開発環境
|
||
|
||
### 🔧 開発ツール
|
||
|
||
- [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) - Chrome拡張
|
||
- [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) - 状態管理デバッグ
|
||
- [VS Code拡張機能集](https://zenn.dev/antez/articles/vscode-extensions-for-react)
|
||
|
||
## サポート方法
|
||
|
||
1. **ペアプログラミング**: 詰まったらすぐに質問
|
||
2. **コードレビュー**: 各Phaseごとにレビュー
|
||
3. **デバッグセッション**: エラーの読み方を一緒に学習
|
||
4. **ベストプラクティス共有**: より良い書き方を随時提案
|
||
|
||
## 学習の進め方アドバイス
|
||
|
||
### 初心者がつまずきやすいポイント
|
||
|
||
1. **エラーメッセージを恐れない**
|
||
- エラーは学習のチャンス
|
||
- エラーメッセージをしっかり読んでコピー&ペーストでGoogle検索
|
||
|
||
2. **小さく始める**
|
||
- いきなり全部を理解しようとしない
|
||
- まず動くものを作ってから理解を深める
|
||
|
||
3. **コードを写経する**
|
||
- 最初はサンプルコードをそのまま写す
|
||
- 動いたら少しずつ変更して実験
|
||
|
||
4. **質問の仕方を覚える**
|
||
- 何をしようとしているのか
|
||
- 何が起きているのか(エラーメッセージ)
|
||
- 何を期待しているのか
|
||
|
||
### 各Phaseの確認ポイント
|
||
|
||
#### Phase 1 終了時
|
||
|
||
- [ ] `npm run dev`でアプリが起動する
|
||
- [ ] ブラウザに「Hello World」が表示される
|
||
- [ ] コンポーネントを自分で作成できる
|
||
|
||
#### Phase 2 終了時
|
||
|
||
- [ ] TODOの入力フォームが表示される
|
||
- [ ] TODOリストが表示される(まだ空でOK)
|
||
- [ ] スタイルが適用されている
|
||
|
||
#### Phase 3 終了時
|
||
|
||
- [ ] TODOを追加できる
|
||
- [ ] TODOを完了/未完了に切り替えられる
|
||
- [ ] TODOを削除できる
|
||
|
||
#### Phase 4 終了時
|
||
|
||
- [ ] TODOを編集できる
|
||
- [ ] フィルタリング機能が動作する
|
||
- [ ] データが永続化される
|
||
|
||
#### Phase 5 終了時
|
||
|
||
- [ ] コンポーネントが適切に分割されている
|
||
- [ ] エラーハンドリングが実装されている
|
||
- [ ] パフォーマンスが最適化されている
|
||
|
||
## FAQ(よくある質問)
|
||
|
||
### Q: `'use client'`を忘れてエラーが出ました
|
||
|
||
**A:** useStateなどのReact Hooksを使う時は、ファイルの先頭に`'use client'`が必要です。Next.js 13+ App Routerでは、デフォルトでServer Componentとして動作するためです。
|
||
|
||
### Q: 「Cannot find module」エラーが出ます
|
||
|
||
**A:** importのパスが間違っている可能性があります。
|
||
|
||
- `./`で始まる相対パス
|
||
- `@/`で始まるエイリアス(tsconfig.jsonで設定)
|
||
- ファイル名の拡張子を確認
|
||
|
||
### Q: スタイルが適用されません
|
||
|
||
**A:**
|
||
|
||
- `className`を使っているか確認(`class`ではない)
|
||
- Tailwind CSSのクラス名が正しいか確認
|
||
- `globals.css`がimportされているか確認
|
||
|
||
### Q: TODOが保存されずリロードすると消えます
|
||
|
||
**A:** Phase 4のLocal Storage実装まではこれが正常です。メモリ上にしかデータが保存されていません。
|
||
|
||
### Q: TypeScriptの型エラーが難しいです
|
||
|
||
**A:**
|
||
|
||
- 最初は`any`型を使ってもOK
|
||
- VSCodeの型情報を活用
|
||
- 公式ドキュメントで型を確認
|
||
- 段階的に適切な型に置き換え
|
||
|
||
### Q: mapやfilterがよく分かりません
|
||
|
||
**A:** これらはJavaScriptの配列メソッドです。
|
||
|
||
- [Array.prototype.map()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map)
|
||
- [Array.prototype.filter()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter)
|
||
|
||
### Q: デバッグの方法が分かりません
|
||
|
||
**A:**
|
||
|
||
- `console.log()`でデータを確認
|
||
- React Developer Toolsを使用
|
||
- ブラウザのDeveloper Toolsを活用
|
||
- エラーメッセージをしっかり読む
|
||
|
||
### Q: Next.jsとReactの違いがよく分かりません
|
||
|
||
**A:**
|
||
|
||
- React:UIライブラリ
|
||
- Next.js:Reactベースのフレームワーク
|
||
- Next.jsはReactに以下を追加:
|
||
- ルーティング
|
||
- SSR/SSG
|
||
- API Routes
|
||
- 最適化機能
|
||
|
||
---
|
||
|
||
このカリキュラムは柔軟に調整可能です。学習者のペースに合わせて進めてください。
|
||
|
||
**🎯 最終目標:実用的なTODOアプリを完成させ、React/Next.js/TypeScriptの基本をマスターする**
|