ReactでTodoアプリを作る #4 編集機能

読了 9分

前回はフィルタリングと一括処理を追加しました。今回は入力したタスクを修正できるように作ります。ダブルクリックで編集モード進入、Enterで保存、Escapeでキャンセルするインライン編集を実装します。この過程で基礎講座で扱わなかったuseRefが初めて登場します。

今回のステップの目標 #

  • 項目テキストをダブルクリックすると入力欄に変身(インライン編集)
  • 入力欄に自動でフォーカスが入る
  • Enterまたはフォーカスを失う (blur) ときに保存
  • Escapeでキャンセル
  • 空のテキストで保存すると項目を削除

編集状態をどこに置く? #

まず編集状態(editingId、編集中の項目のID)をどこに置くかを決定する必要があります。2つの選択肢があります。

オプションA. 各TodoItemが自分の編集状態を持つ

  • 利点: シンプル。親と無関係に動作
  • 欠点: 同時に2つの項目が編集モードになり得る(ユーザー体験上は普通一度に1つだけ編集することを望む)

オプションB. TodoAppeditingIdを持つ

  • 利点: 一度に1項目だけ編集可能(他の項目の編集を始めると自動で前の項目が終了)
  • 欠点: stateが1つ増えてpropsの伝達が必要

今回はオプションBでいきます。同時に複数の項目が編集されるのは違和感があるので。

テキスト更新ハンドラを追加 #

TodoAppupdateTodoText関数を追加し、編集状態(editingId)も作ります。

src/TodoApp.jsxの核心の部分だけお見せします(全体は次のステップで):

src/TodoApp.jsx (修正部分)
const [editingId, setEditingId] = useState(null);

function updateTodoText(id, newText) {
  const trimmed = newText.trim();
  if (!trimmed) {
    deleteTodo(id);
    return;
  }
  setTodos(prev => prev.map(todo =>
    todo.id === id ? { ...todo, text: trimmed } : todo
  ));
}

空のテキストで保存しようとすると自動削除されるようにしました。よくあるUXパターンです。

TodoItem — 編集モード分岐 #

核心はTodoItem現在編集中かによって異なる画面を表示する部分です。通常モードと編集モードが明確に分離されます。

src/TodoItem.jsx:

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

function TodoItem({ todo, isEditing, onToggle, onDelete, onStartEdit, onFinishEdit }) {
  const [draft, setDraft] = useState(todo.text);
  const inputRef = useRef(null);

  useEffect(() => {
    if (isEditing) {
      setDraft(todo.text);
      inputRef.current?.focus();
      inputRef.current?.select();
    }
  }, [isEditing, todo.text]);

  function commit() {
    onFinishEdit(todo.id, draft);
  }

  function cancel() {
    onFinishEdit(todo.id, todo.text);  // 元の値に戻す
  }

  function handleKeyDown(e) {
    if (e.key === 'Enter') commit();
    else if (e.key === 'Escape') cancel();
  }

  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      gap: '8px',
      padding: '8px',
      borderBottom: '1px solid #eee',
      opacity: !isEditing && todo.completed ? 0.5 : 1,
    }}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        disabled={isEditing}
      />
      {isEditing ? (
        <input
          ref={inputRef}
          type="text"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onBlur={commit}
          onKeyDown={handleKeyDown}
          style={{ flex: 1, padding: '4px' }}
        />
      ) : (
        <span
          onDoubleClick={() => onStartEdit(todo.id)}
          style={{
            flex: 1,
            textDecoration: todo.completed ? 'line-through' : 'none',
            cursor: 'text',
          }}
        >
          {todo.text}
        </span>
      )}
      {!isEditing && (
        <button onClick={() => onDelete(todo.id)}>削除</button>
      )}
    </li>
  );
}

export default TodoItem;

初めて見るものが登場しましたが、1つずつ解いてみます。

useRef — DOM要素に直接アクセスする #

const inputRef = useRef(null);
// ...
<input ref={inputRef} ... />
// ...
inputRef.current?.focus();

useRefは変わらずに維持される「参照ボックス」を作ってくれます。ref={inputRef}でinput要素に接続しておくと、レンダリング後にinputRef.currentでそのDOM要素に直接アクセスできます。

useStateとの違いは:

useStateuseRef
値を保持
値が変わると再レンダリング
使用時点画面に影響を与える値画面と無関係に保持する値、DOM参照

ここでのinputRefは画面に影響を与える値ではなく「このinput要素を指すハンドル」にすぎないのでuseRefが適しています。useStateにするとrefを更新するたびに無意味な再レンダリングが起きるでしょう。

編集モード進入時の自動フォーカス #

編集モードに切り替わったらユーザーがいちいちinputをクリックしなくても自動でキーボードフォーカスが入る必要があります。これは#10で学んだuseEffectで処理します。

useEffect(() => {
  if (isEditing) {
    setDraft(todo.text);
    inputRef.current?.focus();
    inputRef.current?.select();
  }
}, [isEditing, todo.text]);
  • isEditingtrueになるときeffectが実行される
  • setDraft(todo.text)でdraftを項目の原本に初期化(前回の編集未完了状態が残らないように)
  • focus()でキーボードフォーカス進入
  • select()で既存のテキストをまるごと選択(ユーザーがすぐに新しいテキストを入力すると上書きされる)

?.(オプショナルチェーン)を使う理由はinputRef.currentnullの場合(まだinputが画面にないとき)を安全に処理するためです。

draft state — 編集中の中間値 #

編集中のテキスト(draft)はTodoItemが自分の中に持ちます。親のtodo.textを直接変えずに、別途保持してユーザーがEnter/blurで「確定」したときだけ親に知らせるのです。キャンセル可能な変更を作るためのよくあるパターンです。

const [draft, setDraft] = useState(todo.text);

このパターンのおかげでEscapeキーを押すと変更されたdraftはそのまま捨てて原本を維持できます。

キーボード処理 #

function handleKeyDown(e) {
  if (e.key === 'Enter') commit();
  else if (e.key === 'Escape') cancel();
}

#6で扱ったキーボードイベント処理そのままです。e.keyは押されたキーの名前文字列です('Enter''Escape''a'など)。

onBlurで自動保存 #

<input onBlur={commit} ... />

inputの外をクリックしてフォーカスを失うと自動で保存されるようにしました。これがないとユーザーが編集後に他のところをクリックしたとき変更が消える可能性があり、不快なUXになります。

ただしonBlurとEscapeの衝突があります — Escapeを押すとcancel()が実行されて原本に戻されますが、その直後にinputが画面から消えながらonBlurが発火してcommit()が再び実行されます。結果としてcancelとcommitが両方とも実行されますが、両方とも同じ仕事(onFinishEditの呼び出し)をするので大きな問題はありません。cancelは原本に戻し、その直後のcommitはすでに原本になっているdraftをそのまま保存するので結果的に原本そのままになるのです。

このような微妙な相互作用がフォーム作業の難しいところですが、小さな単位で動作を検証しながら作っていけば次第に勘がついてきます。

TodoApp統合 #

ではすべての接続を仕上げるコードです。

src/TodoApp.jsx(全体):

src/TodoApp.jsx
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoFilter from './TodoFilter';
import TodoStats from './TodoStats';
import TodoList from './TodoList';

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  const [editingId, setEditingId] = useState(null);

  function addTodo(text) {
    const newTodo = {
      id: crypto.randomUUID(),
      text,
      completed: false,
    };
    setTodos(prev => [newTodo, ...prev]);
  }

  function deleteTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  function toggleTodo(id) {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }

  function clearCompleted() {
    setTodos(prev => prev.filter(todo => !todo.completed));
  }

  function startEdit(id) {
    setEditingId(id);
  }

  function finishEdit(id, newText) {
    const trimmed = newText.trim();
    if (!trimmed) {
      deleteTodo(id);
    } else {
      setTodos(prev => prev.map(todo =>
        todo.id === id ? { ...todo, text: trimmed } : todo
      ));
    }
    setEditingId(null);
  }

  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoFilter filter={filter} onChange={setFilter} />
      <TodoStats todos={todos} onClearCompleted={clearCompleted} />
      <TodoList
        todos={filteredTodos}
        filter={filter}
        totalCount={todos.length}
        editingId={editingId}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
        onStartEdit={startEdit}
        onFinishEdit={finishEdit}
      />
    </div>
  );
}

export default TodoApp;

TodoListも新しいpropsを受け取って子に渡してあげる必要があります。

src/TodoList.jsx:

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

const FILTER_LABEL = {
  all: 'タスク',
  active: '未完了の項目',
  completed: '完了した項目',
};

function TodoList({ todos, filter, totalCount, editingId, onToggle, onDelete, onStartEdit, onFinishEdit }) {
  if (todos.length === 0) {
    if (totalCount === 0) {
      return <p style={{ color: '#888' }}>タスクがありません新しく追加してみてください</p>;
    }
    return <p style={{ color: '#888' }}>{FILTER_LABEL[filter]}がありません</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          isEditing={todo.id === editingId}
          onToggle={onToggle}
          onDelete={onDelete}
          onStartEdit={onStartEdit}
          onFinishEdit={onFinishEdit}
        />
      ))}
    </ul>
  );
}

export default TodoList;

isEditing={todo.id === editingId} — 子にbooleanで単純化して渡しました。子がeditingId === todo.idの比較を毎回しなくてもよいように。子は自分が編集中かどうかだけ知っていればよいです。

動作確認 #

保存して次を試してみてください。

  1. 項目を追加した後にテキストをダブルクリック → 入力欄に変身、自動フォーカス、テキスト全体選択
  2. 文字を修正した後Enter → 保存される
  3. 再度ダブルクリック → 文字を修正 → Escape → 原本復元
  4. ダブルクリック後に入力欄を空にしてEnter → 項目が削除される
  5. ダブルクリック後に他のところをクリック(blur) → 変更された内容で保存
  6. ある項目を編集中に他の項目をダブルクリック → 前の編集は自動終了、新しい項目に編集が移る

3、5で見えるように、draft state分離のおかげで「確定前の変更はいつでもキャンセル可能」な自然なUXが出てきます。

よくある落とし穴 #

1. refをuseStateの代わりに使う #

間違った例
const draftRef = useRef(todo.text);
return <input value={draftRef.current} onChange={(e) => { draftRef.current = e.target.value; }} />;

draftが画面に見える値ならuseStateでなければなりません。refを更新しても再レンダリングが起きないので画面が更新されません。refは画面に現れない値(タイマーID、以前のpropの値の保持など)やDOMハンドルに限定して使ってください。

2. effectの依存性の抜け漏れ #

間違った例
useEffect(() => {
  if (isEditing) inputRef.current?.focus();
}, []);

空の配列だと最初の1回だけ実行されるので、isEditingが後でtrueになってもfocusされません。依存性にisEditingを必ず含めてください。幸いESLintが捕まえてくれます。

3. 一度に2つの項目が編集されるバグ #

editingIdを親に置かず、各TodoItemが自分のisEditingをuseStateで持つと同時に複数の項目が編集モードになり得ます。UX上違和感があるので最初から親で単一IDで管理する方がすっきりします。

ヒント
実際のサービスでインライン編集を作るときはIME(韓国語/日本語入力)とEnterキーの相互作用にも気を配る必要があります。日本語の変換中のEnterは確定の用途であり得るので、即座にcommitすると最後の文字が切れることがあります。onCompositionStart/onCompositionEndイベントで変換中かを追跡してcommitタイミングを遅らせる処理がよく見られます。今回のシリーズでは単純化のため省略しました。

おわりに #

今回の記事ではインライン編集を作りながら新しいツールに出会いました。

  • useRef — 再レンダリングと無関係な値/DOMハンドルの保持
  • useEffect + focus() — モード切り替え時の自動フォーカス
  • draft state — 確定前の変更のキャンセル可能性を作るパターン
  • キーボード処理 — Enter/Escapeでcommit/cancel
  • onBlur自動保存 — ユーザーフレンドリーなUX

これまでの進捗を整理すると追加 / トグル / 削除 / フィルタ / 一括処理 / 編集まですべてできます。ただし1つ大きな問題が残っています — リロードするとすべてのデータが消えます。次の記事であり、このシリーズの最後である「ReactでTodoアプリを作る #5 永続化と締めくくり」では、#13で作ったuseLocalStorageカスタムフックを適用してデータを維持し、シリーズ全体を振り返りながら締めくくります。

{/* TRANSLATOR NOTES

  • IMEの説明で原文「IME(ハングル入力機)」を「IME(韓国語/日本語入力)」と意訳。文脈が日本語読者向けのため、ハングル組み合わせ → 日本語の変換に置き換えました。Glossary追加候補ではない一回限りの用語。 */}
X