Files
nextjs-todo-tutorial/todo-app-curriculum.md
Yuta Sakurai 97d0606a16 TODOアプリ開発カリキュラムを大幅に充実
- 開発環境セットアップセクションを追加(Node.js、VS Code、Git)
- 各Phaseに詳細な解説と日本語参考文献を追加
- コード例を充実させ、「なぜそう書くのか」の説明を追加
- よくあるエラーと対処法、FAQ(よくある質問)セクションを追加
- 段階的な学習目標と確認ポイントを明確化
- トラブルシューティングとデバッグ方法を充実
- パフォーマンス最適化とベストプラクティスまで網羅
2025-07-08 16:11:22 +09:00

52 KiB
Raw Blame History

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以上)

  2. Visual Studio Code

    • 公式サイトからダウンロード
    • 推奨拡張機能:
      • TypeScript and JavaScript Language Features
      • React snippets
      • Tailwind CSS IntelliSense
      • Prettier - Code formatter
      • ESLint
  3. Git

プロジェクトの作成

# プロジェクトの作成
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.tsxlayout.tsxの違い
  • TypeScriptファイル.ts/.tsxの意味

詳細解説:

src/app/
├── layout.tsx    # 全ページ共通のレイアウト
├── page.tsx      # ホームページ(/
├── globals.css   # 全体のスタイル
└── favicon.ico   # ファビコン

📚 このステップの参考文献:

Step 1.2: 最初のコンポーネント作成

学習内容:

  • Hello Worldコンポーネントの作成
  • JSXの基本文法
  • 型定義の初歩stringやnumberの使い方

💡 詳細な実装手順:

  1. 新しいコンポーネントファイルを作成
// 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の予約語と競合を避けるため
  1. コンポーネントを使ってみる
// 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の型安全性が確保されている
  • コードが読みやすく保守性が高い
  • 適切なエラーハンドリングが実装されている
  • パフォーマンスが考慮されている

発展課題

  1. ドラッグ&ドロップでの並び替え

    • react-beautiful-dndライブラリの使用
    • カスタムフックでの実装
  2. カテゴリー機能の追加

    • TODOのカテゴリ分類
    • カテゴリー別フィルタリング
  3. 期限設定機能

    • 期限日の設定
    • 期限切れの警告表示
  4. Next.js API Routesを使ったバックエンド実装

    • データベースとの連携
    • RESTful APIの実装
  5. 認証機能の追加NextAuth.js

    • ユーザー登録・ログイン
    • ユーザー別TODOの管理
  6. PWAProgressive Web App対応

    • オフライン対応
    • プッシュ通知
  7. テストの実装

    • Jest + React Testing Library
    • E2EテストPlaywright

推奨リソース

📚 公式ドキュメント

🎥 日本語の動画教材

📖 日本語の参考記事・書籍

🛠️ 実践的な学習リソース

🔧 開発ツール

サポート方法

  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の配列メソッドです。

Q: デバッグの方法が分かりません

A:

  • console.log()でデータを確認
  • React Developer Toolsを使用
  • ブラウザのDeveloper Toolsを活用
  • エラーメッセージをしっかり読む

Q: Next.jsとReactの違いがよく分かりません

A:

  • ReactUIライブラリ
  • Next.jsReactベースのフレームワーク
  • Next.jsはReactに以下を追加
    • ルーティング
    • SSR/SSG
    • API Routes
    • 最適化機能

このカリキュラムは柔軟に調整可能です。学習者のペースに合わせて進めてください。

🎯 最終目標実用的なTODOアプリを完成させ、React/Next.js/TypeScriptの基本をマスターする