Todo編集機能を実装しよう

学習の目標

本章では、以下の内容を学習します。

  • 配列内の特定の要素を更新する方法を理解する
  • インライン編集UIの実装方法を習得する
  • 編集モードと表示モードの切り替え方法を学ぶ
  • mapメソッドを使った配列要素の更新処理を理解する
  • フォーム送信とキャンセル処理の実装方法を習得する

はじめに

前章では、Todoの削除機能を実装しました。

これで追加と削除ができるようになりましたが、一度作成したTodoの内容を修正することができません。

今回は、 Todo編集機能 を実装して、既存のTodo項目のテキストを変更できるようにします。

これにより、タスクの内容を後から調整したり、より詳細な説明に変更したりできるようになります。

具体的には、Todo項目をクリックすると編集モードに切り替わり、その場でテキストを編集できるインライン編集機能を実装していきます。

編集完了後は、 map メソッドを使って配列内の特定の要素を更新する処理も学習します。

編集モードの状態管理

TodoItemコンポーネントの編集状態

まず、各Todo項目が編集モードかどうかを管理する状態を追加しましょう。

src/TodoItem.jsx を以下のように修正してください。

// src/TodoItem.jsx
import { useState } from 'react';

function TodoItem({ todo, onDeleteTodo }) {
  // 編集モードの状態を管理
  const [isEditing, setIsEditing] = useState(false);
  // 編集中のテキストを管理
  const [editText, setEditText] = useState(todo.text);

  const handleDelete = () => {
    const isConfirmed = window.confirm(`${todo.text}」を削除してもよろしいですか?`);

    if (isConfirmed) {
      onDeleteTodo(todo.id);
    }
  };

  return (
    <div className={`p-3 rounded shadow flex justify-between items-center ${
      todo.completed
        ? 'bg-green-50 border border-green-200'
        : 'bg-white'
    }`}>
      <div className="flex-1">
        <p className={`${
          todo.completed
            ? 'text-gray-500 line-through'
            : 'text-gray-800'
        }`}>
          {todo.text}
        </p>
        <p className="text-sm text-gray-500">
          状態: {todo.completed ? '✅ 完了' : '⏳ 未完了'}
        </p>
      </div>

      <button
        onClick={handleDelete}
        className="ml-4 px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 focus:outline-none"
      >
        削除
      </button>
    </div>
  );
}

export default TodoItem;

ここでは、 useState を使って2つの新しい状態を追加しています。

まずisEditing は、そのTodo項目が現在編集モードかどうかを表すboolean値です。

そしてeditText は、編集中のテキストを一時的に保存するための状態です。

初期値として、 editText には元のTodoのテキスト( todo.text )を設定しています。

編集モードの表示切り替え

表示モードと編集モードのUI

次に、編集モードの時は入力フォームを、通常時はテキストを表示するように切り替え処理を実装しましょう。

src/TodoItem.jsx を以下のように修正します。

// src/TodoItem.jsx
import { useState } from 'react';

function TodoItem({ todo, onDeleteTodo }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleDelete = () => {
    const isConfirmed = window.confirm(`${todo.text}」を削除してもよろしいですか?`);

    if (isConfirmed) {
      onDeleteTodo(todo.id);
    }
  };

  // 編集モード開始
  const handleEditStart = () => {
    setIsEditing(true);
  };

  return (
    <div className={`p-3 rounded shadow flex justify-between items-center ${
      todo.completed
        ? 'bg-green-50 border border-green-200'
        : 'bg-white'
    }`}>
      <div className="flex-1">
        {isEditing ? (
          // 編集モード:入力フォームを表示
          <input
            type="text"
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
          />
        ) : (
          // 表示モード:テキストとクリックイベントを表示
          <div onClick={handleEditStart} className="cursor-pointer">
            <p className={`${
              todo.completed
                ? 'text-gray-500 line-through'
                : 'text-gray-800'
            }`}>
              {todo.text}
            </p>
            <p className="text-sm text-gray-500">
              状態: {todo.completed ? '✅ 完了' : '⏳ 未完了'}
            </p>
          </div>
        )}
      </div>

      <button
        onClick={handleDelete}
        className="ml-4 px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 focus:outline-none"
      >
        削除
      </button>
    </div>
  );
}

export default TodoItem;

この実装では、isEditingの値に応じてUIを切り替えています。

編集モードの時は、input要素を表示して、ユーザーがテキストを編集できるようにしています。 表示モードの時は、テキスト部分をクリックできるようにして、クリックするとhandleEditStart関数が呼ばれて編集モードに切り替わります。

cursor-pointerクラスにより、テキスト部分にマウスを合わせるとカーソルがポインターに変わり、クリックできることがユーザーに分かります。

ブラウザで確認すると、Todo項目のテキスト部分をクリックすると入力フィールドに切り替わることを確認できます。

スクリーンショット

編集の保存とキャンセル処理

編集完了とキャンセルの実装

現在は編集モードに入ることはできますが、編集を保存したりキャンセルしたりする方法がありません。 これらの機能を追加しましょう。

// src/TodoItem.jsx
import { useState } from 'react';

function TodoItem({ todo, onDeleteTodo }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleDelete = () => {
    const isConfirmed = window.confirm(`${todo.text}」を削除してもよろしいですか?`);

    if (isConfirmed) {
      onDeleteTodo(todo.id);
    }
  };

  const handleEditStart = () => {
    setIsEditing(true);
  };

  // 編集をキャンセル
  const handleEditCancel = () => {
    setEditText(todo.text); // 元のテキストに戻す
    setIsEditing(false);
  };

  // 編集を保存(一時的な処理)
  const handleEditSave = () => {
    if (editText.trim() === '') {
      return; // 空の場合は保存しない
    }

    // TODO: ここで実際の更新処理を行う
    console.log('保存するテキスト:', editText);
    setIsEditing(false);
  };

  // Enterキーで保存、Escapeキーでキャンセル
  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      handleEditSave();
    } else if (e.key === 'Escape') {
      handleEditCancel();
    }
  };

  return (
    <div className={`p-3 rounded shadow flex justify-between items-center ${
      todo.completed
        ? 'bg-green-50 border border-green-200'
        : 'bg-white'
    }`}>
      <div className="flex-1">
        {isEditing ? (
          // 編集モード:入力フォームとボタンを表示
          <div className="flex gap-2">
            <input
              type="text"
              value={editText}
              onChange={(e) => setEditText(e.target.value)}
              onKeyDown={handleKeyDown}
              className="flex-1 px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
              autoFocus
            />
            <button
              onClick={handleEditSave}
              className="px-2 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600"
            >
              保存
            </button>
            <button
              onClick={handleEditCancel}
              className="px-2 py-1 bg-gray-500 text-white text-sm rounded hover:bg-gray-600"
            >
              キャンセル
            </button>
          </div>
        ) : (
          // 表示モード:テキストとクリックイベントを表示
          <div onClick={handleEditStart} className="cursor-pointer">
            <p className={`${
              todo.completed
                ? 'text-gray-500 line-through'
                : 'text-gray-800'
            }`}>
              {todo.text}
            </p>
            <p className="text-sm text-gray-500">
              状態: {todo.completed ? '✅ 完了' : '⏳ 未完了'}
            </p>
          </div>
        )}
      </div>

      <button
        onClick={handleDelete}
        className="ml-4 px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 focus:outline-none"
      >
        削除
      </button>
    </div>
  );
}

export default TodoItem;

この実装では、編集モード時に保存ボタンとキャンセルボタンを追加しています。

handleEditCancel関数では、編集中のテキストを元の値に戻してから編集モードを終了します。 handleEditSave関数では、空のテキストでないことを確認してから保存処理を行います(現在はログ出力のみ)。

また、handleKeyDown関数により、Enterキーで保存、Escapeキーでキャンセルができるようになっています。 autoFocus属性により、編集モードに入ると自動的に入力フィールドにフォーカスが移ります。

スクリーンショット

ただし、まだ保存処理は実装されていません。

次は、実際にTodoのテキストを更新する処理を実装していきます。

Todo更新機能の実装

ここでは、以下の順序でTodoの更新機能を実装していきます。

  1. Todoの更新関数をApp.jsxに追加
  2. TodoListコンポーネントに更新関数を渡す
  3. TodoItemコンポーネントで更新処理を実装

App.jsxに更新関数を追加

実際にTodoのテキストを更新する処理をApp.jsxに実装しましょう。

src/App.jsxを以下のように修正します。

import { useState } from 'react';
import TodoList from './TodoList';
import TodoForm from './TodoForm';

function App() {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: "Reactの基礎を学ぶ",
      completed: false
    },
    {
      id: 2,
      text: "Todoアプリを作成する",
      completed: false
    },
    {
      id: 3,
      text: "JavaScriptの復習をする",
      completed: true
    }
  ]);

  const generateNewId = () => {
    if (todos.length === 0) return 1;
    const maxId = Math.max(...todos.map(todo => todo.id));
    return maxId + 1;
  };

  const addTodo = (text) => {
    const newTodo = {
      id: generateNewId(),
      text: text,
      completed: false
    };

    setTodos([...todos, newTodo]);
  };

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

  // Todo更新関数を追加
  const updateTodo = (id, newText) => {
    const updatedTodos = todos.map(todo =>
      todo.id === id ? { ...todo, text: newText } : todo
    );
    setTodos(updatedTodos);
  };

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-md mx-auto">
        <h1 className="text-3xl font-bold text-center text-gray-800 mb-8">
          Todo アプリ
        </h1>

        <TodoForm onAddTodo={addTodo} />

        <TodoList todos={todos} onDeleteTodo={deleteTodo} />
      </div>
    </div>
  )
}

export default App

updateTodo関数では、mapメソッドを使って配列内の特定の要素を更新しています。

mapメソッドは各要素に対して処理を行い、新しい配列を返します。 更新対象のIDと一致する場合は、スプレッド演算子(...todo)で既存のプロパティをコピーしつつ、textプロパティだけを新しい値に更新しています。 一致しない場合は、元のTodoオブジェクトをそのまま返します。

TodoListコンポーネントに更新関数を渡す

作成した更新関数をTodoListコンポーネントを通じてTodoItemコンポーネントまで渡しましょう。

まず、App.jsxでTodoListに更新関数を渡します。

import { useState } from 'react';
import TodoList from './TodoList';
import TodoForm from './TodoForm';

function App() {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: "Reactの基礎を学ぶ",
      completed: false
    },
    {
      id: 2,
      text: "Todoアプリを作成する",
      completed: false
    },
    {
      id: 3,
      text: "JavaScriptの復習をする",
      completed: true
    }
  ]);

  const generateNewId = () => {
    if (todos.length === 0) return 1;
    const maxId = Math.max(...todos.map(todo => todo.id));
    return maxId + 1;
  };

  const addTodo = (text) => {
    const newTodo = {
      id: generateNewId(),
      text: text,
      completed: false
    };

    setTodos([...todos, newTodo]);
  };

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

  const updateTodo = (id, newText) => {
    const updatedTodos = todos.map(todo =>
      todo.id === id ? { ...todo, text: newText } : todo
    );
    setTodos(updatedTodos);
  };

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-md mx-auto">
        <h1 className="text-3xl font-bold text-center text-gray-800 mb-8">
          Todo アプリ
        </h1>

        <TodoForm onAddTodo={addTodo} />

        {/* 更新関数をPropsとして渡す */}
        <TodoList
          todos={todos}
          onDeleteTodo={deleteTodo}
          onUpdateTodo={updateTodo}
        />
      </div>
    </div>
  )
}

export default App

TodoListコンポーネントの更新

次に、src/TodoList.jsxを修正して、更新関数をTodoItemコンポーネントに渡します。

// src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos, onDeleteTodo, onUpdateTodo }) {
  return (
    <div className="space-y-2">
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onDeleteTodo={onDeleteTodo}
          onUpdateTodo={onUpdateTodo} // 更新関数を渡す
        />
      ))}
    </div>
  );
}

export default TodoList;

TodoItemコンポーネントでの更新処理

最後に、src/TodoItem.jsxを修正して、実際に更新処理を呼び出すようにしましょう。

// src/TodoItem.jsx
import { useState } from 'react';

function TodoItem({ todo, onDeleteTodo, onUpdateTodo }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleDelete = () => {
    const isConfirmed = window.confirm(`${todo.text}」を削除してもよろしいですか?`);

    if (isConfirmed) {
      onDeleteTodo(todo.id);
    }
  };

  const handleEditStart = () => {
    setIsEditing(true);
  };

  const handleEditCancel = () => {
    setEditText(todo.text);
    setIsEditing(false);
  };

  // 編集を保存
  const handleEditSave = () => {
    if (editText.trim() === '') {
      return;
    }

    // 実際の更新処理を呼び出す
    onUpdateTodo(todo.id, editText.trim());
    setIsEditing(false);
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      handleEditSave();
    } else if (e.key === 'Escape') {
      handleEditCancel();
    }
  };

  return (
    <div className={`p-3 rounded shadow flex justify-between items-center ${
      todo.completed
        ? 'bg-green-50 border border-green-200'
        : 'bg-white'
    }`}>
      <div className="flex-1">
        {isEditing ? (
          <div className="flex gap-2">
            <input
              type="text"
              value={editText}
              onChange={(e) => setEditText(e.target.value)}
              onKeyDown={handleKeyDown}
              className="flex-1 px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
              autoFocus
            />
            <button
              onClick={handleEditSave}
              className="px-2 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600"
            >
              保存
            </button>
            <button
              onClick={handleEditCancel}
              className="px-2 py-1 bg-gray-500 text-white text-sm rounded hover:bg-gray-600"
            >
              キャンセル
            </button>
          </div>
        ) : (
          <div onClick={handleEditStart} className="cursor-pointer">
            <p className={`${
              todo.completed
                ? 'text-gray-500 line-through'
                : 'text-gray-800'
            }`}>
              {todo.text}
            </p>
            <p className="text-sm text-gray-500">
              状態: {todo.completed ? '✅ 完了' : '⏳ 未完了'}
            </p>
          </div>
        )}
      </div>

      <button
        onClick={handleDelete}
        className="ml-4 px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 focus:outline-none"
      >
        削除
      </button>
    </div>
  );
}

export default TodoItem;

handleEditSave関数で、親コンポーネントから受け取ったonUpdateTodo関数を呼び出し、TodoのIDと新しいテキストを引数として渡しています。

これで、編集機能が完全に動作するようになります。

動作確認とテスト

基本的な編集機能のテスト

ここまでの実装が完了したら、実際に編集機能をテストしてみましょう。

ブラウザで以下の操作を試してみてください。

まず、任意のTodo項目のテキスト部分をクリックして、編集モードに切り替わることを確認してください。

入力フィールドでテキストを変更して、「保存」ボタンをクリックまたはEnterキーを押して、変更が反映されることを確認してください。

スクリーンショット

スクリーンショット

キャンセル機能のテスト

編集途中でキャンセルする機能もテストしてみましょう。

Todo項目を編集モードにして、テキストを変更してから「キャンセル」ボタンをクリックまたはEscapeキーを押してください。 変更がキャンセルされて、元のテキストに戻ることを確認してください。

複数項目の編集テスト

複数のTodo項目を順番に編集してみて、それぞれ独立して編集できることを確認してください。

各Todo項目の編集状態は個別に管理されているため、1つの項目を編集中でも他の項目をクリックして別の編集を開始できることを確認してください。

これは、ちゃんと編集対象のTodo項目ごとに状態が管理されていることを意味します。

まとめ

本章では、Todo編集機能を実装しました。 学習できた内容は以下の通りです。

インライン編集UIの実装方法を習得し、編集モードと表示モードの切り替え処理を学びました。 また、mapメソッドを使って配列内の特定の要素を更新する方法を理解しました。

さらに、キーボードイベント(EnterキーとEscapeキー)を使った操作性の向上や、autoFocusを使ったユーザビリティの改善も実装しました。 コンポーネント間での関数の受け渡しにより、編集処理を適切に分離できました。

これで、Todoアプリに追加・削除・編集の主要な機能が揃いました。 ユーザーは自由にTodoを管理できる、実用的なアプリケーションになっています。

次の章では、Todo項目の完了・未完了を切り替える機能を実装していきます。 チェックボックスを使った直感的な操作で、タスクの進捗管理ができるようにしていきましょう。

このセクションは有料サブスクリプションへの登録、またはログインが必要です。完全なコンテンツにアクセスするには、料金ページ(/pricing)をご覧ください。購入済みの場合は、ログインしてください。

Basicプランでより詳しく学習

この先のコンテンツを読むにはBasicプラン以上が必要です。より詳細な解説、実践的なサンプルコード、演習問題にアクセスして学習を深めましょう。

作成者:とまだ
Previous
Todo削除機能を実装しよう