AI時代のReact入門 第14回:シンプルなTodoアプリを作る(総合演習)

「ここまで学んだReactの知識を、実際のアプリケーション開発に活かしたい!」

useStateでの状態管理、イベントハンドリング、フォームの実装など、これまで個別に学んできた知識を組み合わせて、実際のアプリケーションを作ってみましょう。

今回は、シンプルなTodoアプリの開発を通じて、Reactの基本的な機能を総復習します。V0とCursorを活用しながら、CRUD(作成・読取・更新・削除)機能を持つアプリケーションの実装方法を学んでいきましょう。

シンプルなTodoアプリを作る:React基礎の総復習

「ここまで学んだReactの知識を、実際のアプリケーション開発に活かしたい!」

useStateでの状態管理、イベントハンドリング、フォームの実装など、これまで個別に学んできた知識を組み合わせて、実際のアプリケーションを作ってみましょう。

今回は、シンプルなTodoアプリの開発を通じて、Reactの基本的な機能を総復習します。V0とCursorを活用しながら、CRUD(作成・読取・更新・削除)機能を持つアプリケーションの実装方法を学んでいきましょう。

Step 1: V0でベース実装を生成

まず、V0に以下のように依頼して、基本的なTodoアプリを生成します:

シンプルなTodoアプリを作りたいです:
- タスクの追加、表示機能
- TailwindCSSでスタイリング
- useStateを使った状態管理
Reactコンポーネントを作成してください。

V0は以下のようなコードを生成してくれました:

jsx
import React, { useState } from 'react';

const TodoApp = () => {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    
    setTodos([...todos, {
      id: Date.now(),
      text: input,
      completed: false
    }]);
    setInput('');
  };

  return (
    <div className="max-w-md mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Todoリスト</h1>
      
      <form onSubmit={handleSubmit} className="mb-4">
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            className="flex-1 px-3 py-2 border rounded"
            placeholder="新しいタスクを入力..."
          />
          <button
            type="submit"
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
          >
            追加
          </button>
        </div>
      </form>

      <ul className="space-y-2">
        {todos.map(todo => (
          <li
            key={todo.id}
            className="flex items-center p-3 border rounded"
          >
            <span>{todo.text}</span>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoApp;

Step 2: Cursorで機能を改善

基本的な実装ができたところで、Cursorに以下のように依頼して機能を追加していきます:

このTodoアプリに以下の機能を追加したいです:
1. タスクの完了状態の切り替え
2. 削除機能
3. 編集機能
実装方法を提案してください。

Cursorの提案を取り入れた改善版はこちらです:

jsx
import React, { useState } from 'react';

const TodoApp = () => {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');
  const [editingId, setEditingId] = useState(null);
  const [editInput, setEditInput] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    
    setTodos([...todos, {
      id: Date.now(),
      text: input,
      completed: false
    }]);
    setInput('');
  };

  const toggleComplete = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  const startEditing = (todo) => {
    setEditingId(todo.id);
    setEditInput(todo.text);
  };

  const handleEdit = (id) => {
    if (!editInput.trim()) return;
    
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, text: editInput } : todo
    ));
    setEditingId(null);
    setEditInput('');
  };

  return (
    <div className="max-w-md mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Todoリスト</h1>
      
      <form onSubmit={handleSubmit} className="mb-4">
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            className="flex-1 px-3 py-2 border rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="新しいタスクを入力..."
          />
          <button
            type="submit"
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
          >
            追加
          </button>
        </div>
      </form>

      <ul className="space-y-2">
        {todos.map(todo => (
          <li
            key={todo.id}
            className="flex items-center p-3 border rounded hover:shadow-md transition-shadow"
          >
            {editingId === todo.id ? (
              <div className="flex items-center gap-2 flex-1">
                <input
                  type="text"
                  value={editInput}
                  onChange={(e) => setEditInput(e.target.value)}
                  className="flex-1 px-2 py-1 border rounded"
                  autoFocus
                />
                <button
                  onClick={() => handleEdit(todo.id)}
                  className="px-3 py-1 text-sm text-white bg-green-500 rounded hover:bg-green-600"
                >
                  保存
                </button>
                <button
                  onClick={() => setEditingId(null)}
                  className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
                >
                  キャンセル
                </button>
              </div>
            ) : (
              <>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleComplete(todo.id)}
                  className="mr-3"
                />
                <span className={`flex-1 ${todo.completed ? 'line-through text-gray-400' : ''}`}>
                  {todo.text}
                </span>
                <button
                  onClick={() => startEditing(todo)}
                  className="px-2 text-gray-500 hover:text-gray-700"
                >
                  編集
                </button>
                <button
                  onClick={() => deleteTodo(todo.id)}
                  className="px-2 text-red-500 hover:text-red-700"
                >
                  削除
                </button>
              </>
            )}
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p className="text-center text-gray-500 mt-4">
          タスクがありません。新しいタスクを追加してください。
        </p>
      )}
    </div>
  );
};

export default TodoApp;

Step 3: 機能の拡張

さらに、Cursorに以下のように依頼して機能を拡張します:

追加の機能を実装したいです:
1. フィルター機能(全て/未完了/完了済み)
2. ローカルストレージへの保存
3. タスクの並び替え
実装方法を提案してください。
jsx
import React, { useState, useEffect } from 'react';

const TodoApp = () => {
  const [todos, setTodos] = useState(() => {
    // ローカルストレージからの初期データ読み込み
    const savedTodos = localStorage.getItem('todos');
    return savedTodos ? JSON.parse(savedTodos) : [];
  });
  const [input, setInput] = useState('');
  const [editingId, setEditingId] = useState(null);
  const [editInput, setEditInput] = useState('');
  const [filter, setFilter] = useState('all');
  const [sortBy, setSortBy] = useState('date');

  // ローカルストレージへの保存
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    
    setTodos([...todos, {
      id: Date.now(),
      text: input,
      completed: false,
      createdAt: new Date().toISOString()
    }]);
    setInput('');
  };

  const toggleComplete = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    if (window.confirm('このタスクを削除してもよろしいですか?')) {
      setTodos(todos.filter(todo => todo.id !== id));
    }
  };

  const startEditing = (todo) => {
    setEditingId(todo.id);
    setEditInput(todo.text);
  };

  const handleEdit = (id) => {
    if (!editInput.trim()) return;
    
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, text: editInput } : todo
    ));
    setEditingId(null);
    setEditInput('');
  };

  // フィルタリングされたTodoの取得
  const getFilteredTodos = () => {
    let filtered = [...todos];
    
    // フィルター適用
    switch (filter) {
      case 'active':
        filtered = filtered.filter(todo => !todo.completed);
        break;
      case 'completed':
        filtered = filtered.filter(todo => todo.completed);
        break;
      default:
        break;
    }
    
    // ソート適用
    switch (sortBy) {
      case 'date':
        filtered.sort((a, b) => b.id - a.id);
        break;
      case 'alphabetical':
        filtered.sort((a, b) => a.text.localeCompare(b.text));
        break;
      case 'completed':
        filtered.sort((a, b) => Number(a.completed) - Number(b.completed));
        break;
      default:
        break;
    }
    
    return filtered;
  };

  const filteredTodos = getFilteredTodos();

  return (
    <div className="max-w-md mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Todoリスト</h1>
      
      <form onSubmit={handleSubmit} className="mb-4">
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            className="flex-1 px-3 py-2 border rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="新しいタスクを入力..."
          />
          <button
            type="submit"
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
          >
            追加
          </button>
        </div>
      </form>

      <div className="flex justify-between mb-4">
        <div className="space-x-2">
          <select
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
            className="px-2 py-1 border rounded"
          >
            <option value="all">すべて</option>
            <option value="active">未完了</option>
            <option value="completed">完了済み</option>
          </select>

          <select
            value={sortBy}
            onChange={(e) => setSortBy(e.target.value)}
            className="px-2 py-1 border rounded"
          >
            <option value="date">日付順</option>
            <option value="alphabetical">名前順</option>
            <option value="completed">完了状態順</option>
          </select>
        </div>

        {todos.length > 0 && (
          <button
            onClick={() => {
              if (window.confirm('すべてのタスクを削除してもよろしいですか?')) {
                setTodos([]);
              }
            }}
            className="text-sm text-red-500 hover:text-red-700"
          >
            すべて削除
          </button>
        )}
      </div>

      <ul className="space-y-2">
        {filteredTodos.map(todo => (
          <li
            key={todo.id}
            className="flex items-center p-3 border rounded hover:shadow-md transition-shadow"
          >
            {editingId === todo.id ? (
              <div className="flex items-center gap-2 flex-1">
                <input
                  type="text"
                  value={editInput}
                  onChange={(e) => setEditInput(e.target.value)}
                  className="flex-1 px-2 py-1 border rounded"
                  autoFocus
                />
                <button
                  onClick={() => handleEdit(todo.id)}
                  className="px-3 py-1 text-sm text-white bg-green-500 rounded hover:bg-green-600"
                >
                  保存
                </button>
                <button
                  onClick={() => setEditingId(null)}
                  className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
                >
                  キャンセル
                </button>
              </div>
            ) : (
              <>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleComplete(todo.id)}
                  className="mr-3"
                />
                <span className={`flex-1 ${todo.completed ? 'line-through text-gray-400' : ''}`}>
                  {todo.text}
                </span>
                <button
                  onClick={() => startEditing(todo)}
                  className="px-2 text-gray-500 hover:text-gray-700"
                >
                  編集
                </button>
                <button
                  onClick={() => deleteTodo(todo.id)}
                  className="px-2 text-red-500 hover:text-red-700"
                >
                  削除
                </button>
              </>
            )}
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p className="text-center text-gray-500 mt-4">
          タスクがありません。新しいタスクを追加してください。
        </p>
      )}
    </div>
  );
};

export default TodoApp;

実装のポイント解説

1. 状態管理の設計

  • 複数の状態を適切に分割(todos, input, editingId, filter, sortBy)
  • ローカルストレージとの連携による永続化
  • フィルタリングとソート機能の実装

2. イベントハンドリング

  • フォーム送信(handleSubmit)
  • タスクの操作(toggleComplete, deleteTodo)
  • 編集モードの切り替え(startEditing, handleEdit)

3. UIの改善

  • フィルターとソートのドロップダウン
  • 編集モードの切り替えアニメーション
  • レスポンシブデザイン

4. ユーザビリティの向上

  • 確認ダイアログの追加
  • エラー処理の実装
  • ローディング状態の表示

コードの再利用性を高める工夫

V0で生成したコードを改善する際、以下のような点に注目しました:

  1. コンポーネントの分割
    • TodoItemコンポーネント
    • TodoFormコンポーネント
    • FilterControlsコンポーネント
  2. カスタムフックの作成
    • useLocalStorage
    • useTodoFilters
    • useTodoActions

まとめ

今回は、V0とCursorを活用してTodoアプリを段階的に実装しました:

  1. V0で基本的な機能を生成
  2. Cursorで機能を拡張
  3. UIとUXを改善
  4. データの永続化を実装