- 開発環境セットアップセクションを追加(Node.js、VS Code、Git) - 各Phaseに詳細な解説と日本語参考文献を追加 - コード例を充実させ、「なぜそう書くのか」の説明を追加 - よくあるエラーと対処法、FAQ(よくある質問)セクションを追加 - 段階的な学習目標と確認ポイントを明確化 - トラブルシューティングとデバッグ方法を充実 - パフォーマンス最適化とベストプラクティスまで網羅
52 KiB
TODOアプリ開発カリキュラム (Next.js + TypeScript)
概要
このカリキュラムは、JavaScript/TypeScript初学者がNext.js 15 (App Router)を使って実践的なTODOアプリを開発できるようになることを目的としています。
学習目標
- React/Next.jsの基本的な概念を理解する
- TypeScriptの型システムに慣れる
- 状態管理の基本を学ぶ
- CRUDアプリケーションの実装パターンを習得する
- モダンなWeb開発のベストプラクティスを身につける
前提知識
- HTML/CSSの基礎知識
- JavaScriptの基本文法(変数、関数、配列、オブジェクト)
- コマンドラインの基本操作
開発環境セットアップ
必要なツール
-
Node.js (v18.17以上)
- Node.js公式サイトからダウンロード
node --versionで確認
-
Visual Studio Code
- 公式サイトからダウンロード
- 推奨拡張機能:
- TypeScript and JavaScript Language Features
- React snippets
- Tailwind CSS IntelliSense
- Prettier - Code formatter
- ESLint
-
Git
- Git公式サイトからダウンロード
git --versionで確認
プロジェクトの作成
# プロジェクトの作成
npx create-next-app@latest my-todo-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
# プロジェクトディレクトリに移動
cd my-todo-app
# 開発サーバーの起動
npm run dev
📚 参考文献:
学習ステップ
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 # ファビコン
📚 このステップの参考文献:
Step 1.2: 最初のコンポーネント作成
学習内容:
- Hello Worldコンポーネントの作成
- JSXの基本文法
- 型定義の初歩(stringやnumberの使い方)
💡 詳細な実装手順:
- 新しいコンポーネントファイルを作成
// 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の予約語と競合を避けるため)
- コンポーネントを使ってみる
// 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し忘れていないか確認
📚 このステップの参考文献:
Phase 2: TODOアプリの骨組み作成 (3-4時間)
学習目標
- React Hooksの基本(useState)を理解する
- TypeScriptでの型定義を学ぶ
- フォームの基本的な操作を身につける
Step 2.1: UIコンポーネントの作成
学習内容:
- TODOリストの表示部分
- TODO入力フォーム
- Tailwind CSSによるスタイリング
💡 詳細な実装手順:
// 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- 子要素間の垂直間隔
📚 このステップの参考文献:
Step 2.2: 状態管理の導入
学習内容:
useStateフックの使い方- TODOアイテムの型定義
- 状態とUIの連携
💡 詳細な実装手順:
// 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が効率的に更新するために必要
📚 このステップの参考文献:
Phase 3: 機能実装 (4-5時間)
学習目標
- CRUD操作の基本を理解する
- イベントハンドラーの実装方法を学ぶ
- 配列の操作(追加、更新、削除)を身につける
Step 3.1: TODO追加機能
学習内容:
- フォーム送信処理
- 新しいTODOの作成
- リストへの反映
💡 詳細な実装と解説:
// 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のデフォルト動作(ページリロード)を止める
📚 このステップの参考文献:
Step 3.2: TODO完了機能
学習内容:
- チェックボックスの実装
- 完了状態の切り替え
- 条件付きスタイリング
💡 詳細な実装と解説:
// 完了状態を切り替える関数
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のクラス)
よくあるミス:
// ❌ これは動かない!直接配列を変更している
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed; // Reactが変更を検知できない
setTodos(todos); // 同じ配列をセットしても再レンダリングされない
}
📚 このステップの参考文献:
Step 3.3: TODO削除機能
学習内容:
- 削除ボタンの追加
- 配列からの要素削除
- 確認ダイアログ(オプション)
💡 詳細な実装と解説:
// 削除関数
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を返す)
カスタム確認ダイアログの例:
// 状態でモーダルを管理
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>
)}
📚 このステップの参考文献:
Phase 4: 高度な機能 (3-4時間)
学習目標
- 複雑な状態管理パターンを理解する
- インライン編集の実装方法を学ぶ
- データの永続化について理解する
Step 4.1: TODO編集機能
学習内容:
- インライン編集の実装
- 保存とキャンセル処理
- 複数状態の管理
💡 詳細な実装と解説:
// 編集用の状態管理
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- 編集開始時に自動的にフォーカス- 三項演算子
? :- 条件によって表示を切り替え
📚 このステップの参考文献:
Step 4.2: フィルタリング機能
学習内容:
- All/Active/Completedの切り替え
- 条件付きレンダリング
- 状態による表示制御
💡 詳細な実装と解説:
// フィルタリング用の型定義
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>
📚 このステップの参考文献:
Step 4.3: データの永続化
学習内容:
- Local Storageの活用
useEffectフックの理解- データの保存と読み込み
💡 詳細な実装と解説:
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 で関数を返す
📚 このステップの参考文献:
Phase 5: 仕上げとベストプラクティス (2-3時間)
学習目標
- コンポーネントの分割と再利用性を理解する
- パフォーマンス最適化の基本を学ぶ
- エラーハンドリングの実装方法を身につける
Step 5.1: コンポーネントの分割
学習内容:
- 再利用可能なコンポーネント化
- propsの型定義
- 関心の分離
💡 詳細な実装と解説:
// 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>
);
}
コンポーネント分割のメリット:
- 単一責任の原則に従う
- 再利用性が高まる
- テストが書きやすくなる
- コードが読みやすくなる
📚 このステップの参考文献:
Step 5.2: エラーハンドリング
学習内容:
- 入力検証の強化
- エラーメッセージの表示
- エラーバウンダリーの実装
💡 詳細な実装と解説:
// 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>
);
}
📚 このステップの参考文献:
Step 5.3: パフォーマンス最適化
学習内容:
useCallbackの基礎useMemoの基礎- キー属性の重要性
💡 詳細な実装と解説:
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属性の使用
📚 このステップの参考文献:
つまずきやすいポイントと対策
1. 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. 状態更新のイミュータビリティ
よくある間違いと正しい書き方:
// ❌ 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の依存配列
よくある間違い:
// ❌ 依存配列を忘れる(無限ループの原因)
useEffect(() => {
setTodos(todos.filter(todo => todo.completed));
}); // 依存配列がない
// ✅ 正しい書き方
useEffect(() => {
// 何らかの処理
}, [todos]); // 依存配列を指定
4. イベントハンドラーの型
正しい型定義:
// フォーム送信
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の型安全性が確保されている
- コードが読みやすく保守性が高い
- 適切なエラーハンドリングが実装されている
- パフォーマンスが考慮されている
発展課題
-
ドラッグ&ドロップでの並び替え
- react-beautiful-dndライブラリの使用
- カスタムフックでの実装
-
カテゴリー機能の追加
- TODOのカテゴリ分類
- カテゴリー別フィルタリング
-
期限設定機能
- 期限日の設定
- 期限切れの警告表示
-
Next.js API Routesを使ったバックエンド実装
- データベースとの連携
- RESTful APIの実装
-
認証機能の追加(NextAuth.js)
- ユーザー登録・ログイン
- ユーザー別TODOの管理
-
PWA(Progressive Web App)対応
- オフライン対応
- プッシュ通知
-
テストの実装
- Jest + React Testing Library
- E2Eテスト(Playwright)
推奨リソース
📚 公式ドキュメント
🎥 日本語の動画教材
- 【2024年最新】React完全入門ガイド|Hooks、Next.js、TypeScriptまで
- Next.js入門 - App Routerを基礎から学ぶ
- TypeScript入門 完全版
- React Hooks完全入門
📖 日本語の参考記事・書籍
- サバイバルTypeScript - TypeScriptの実践的な解説
- React入門 TODOアプリ開発 - Zennの関連記事集
- Next.js App Router入門 - 詳細な解説記事
- モダンJavaScript入門 - JavaScript基礎の復習
- 書籍:「React実践入門」(技術評論社)
- 書籍:「TypeScript実践プログラミング」(マイナビ出版)
🛠️ 実践的な学習リソース
- React Tutorial: Tic-Tac-Toe(日本語版) - 公式チュートリアル
- TypeScript Playground - ブラウザで試せる
- StackBlitz - オンラインでNext.jsを試せる
- CodeSandbox - オンライン開発環境
🔧 開発ツール
- React Developer Tools - Chrome拡張
- Redux DevTools - 状態管理デバッグ
- VS Code拡張機能集
サポート方法
- ペアプログラミング: 詰まったらすぐに質問
- コードレビュー: 各Phaseごとにレビュー
- デバッグセッション: エラーの読み方を一緒に学習
- ベストプラクティス共有: より良い書き方を随時提案
学習の進め方アドバイス
初心者がつまずきやすいポイント
-
エラーメッセージを恐れない
- エラーは学習のチャンス
- エラーメッセージをしっかり読んでコピー&ペーストでGoogle検索
-
小さく始める
- いきなり全部を理解しようとしない
- まず動くものを作ってから理解を深める
-
コードを写経する
- 最初はサンプルコードをそのまま写す
- 動いたら少しずつ変更して実験
-
質問の仕方を覚える
- 何をしようとしているのか
- 何が起きているのか(エラーメッセージ)
- 何を期待しているのか
各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の配列メソッドです。
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の基本をマスターする