Chapter 06

Todo App を作ろう

バニラ JavaScript でフル CRUD の Todo アプリを作り、DOM 操作の実践力を鍛えます。

45 min

このチャプターで学ぶこと

  • 配列でデータを管理し、DOM に反映するパターンを理解できる
  • 追加・完了切り替え・削除の CRUD 操作を実装できる
  • データ配列と DOM 表示を同期させるrenderパターンを使える
  • localStorage でデータを永続化できる
  • DOM 操作コードの量と複雑さを実感できる

完成形の確認

このチャプターでは、バニラ JavaScript だけを使って本格的な Todo アプリを作ります。完成形のアプリは以下の機能を持ちます。

このアプリを完成させることで、DOM 操作のパターンを深く理解できます。また、このコードの量と複雑さを体感することが、後でReact を学ぶときの大きなモチベーションになります。


HTMLのベースを作る

まず、アプリの骨格となる HTML を作ります。todo-app/ というフォルダを作り、以下のファイルを用意しましょう。

<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Todo アプリ</title>
  <style>
    /* ---- リセット & ベーススタイル ---- */
    *,
    *::before,
    *::after {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f0f2f5;
      min-height: 100vh;
      display: flex;
      align-items: flex-start;
      justify-content: center;
      padding: 2rem 1rem;
    }

    /* ---- アプリコンテナ ---- */
    .app {
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
      padding: 2rem;
      width: 100%;
      max-width: 520px;
    }

    h1 {
      font-size: 1.75rem;
      font-weight: 700;
      color: #1a202c;
      margin-bottom: 1.5rem;
      text-align: center;
    }

    /* ---- 入力フォーム ---- */
    .input-area {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1.25rem;
    }

    .todo-input {
      flex: 1;
      padding: 0.6rem 0.875rem;
      border: 2px solid #e2e8f0;
      border-radius: 8px;
      font-size: 1rem;
      outline: none;
      transition: border-color 0.2s;
    }

    .todo-input:focus {
      border-color: #667eea;
    }

    .add-btn {
      padding: 0.6rem 1.25rem;
      background: #667eea;
      color: #fff;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.2s;
    }

    .add-btn:hover {
      background: #5a67d8;
    }

    /* ---- フィルタータブ ---- */
    .filters {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1rem;
    }

    .filter-btn {
      flex: 1;
      padding: 0.4rem 0.75rem;
      border: 2px solid #e2e8f0;
      border-radius: 6px;
      background: transparent;
      font-size: 0.875rem;
      cursor: pointer;
      transition: all 0.2s;
      color: #4a5568;
    }

    .filter-btn.active {
      background: #667eea;
      border-color: #667eea;
      color: #fff;
      font-weight: 600;
    }

    /* ---- Todo リスト ---- */
    .todo-list {
      list-style: none;
      margin-bottom: 1rem;
    }

    .todo-item {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      padding: 0.75rem 0.5rem;
      border-bottom: 1px solid #edf2f7;
      transition: background 0.15s;
    }

    .todo-item:last-child {
      border-bottom: none;
    }

    .todo-item:hover {
      background: #f7fafc;
      border-radius: 6px;
    }

    .todo-checkbox {
      width: 1.125rem;
      height: 1.125rem;
      cursor: pointer;
      accent-color: #667eea;
    }

    .todo-text {
      flex: 1;
      font-size: 1rem;
      color: #2d3748;
    }

    /* 完了状態のスタイル */
    .todo-text.completed {
      text-decoration: line-through;
      color: #a0aec0;
    }

    .delete-btn {
      padding: 0.25rem 0.625rem;
      background: transparent;
      border: 1px solid #fc8181;
      color: #fc8181;
      border-radius: 4px;
      font-size: 0.75rem;
      cursor: pointer;
      transition: all 0.2s;
    }

    .delete-btn:hover {
      background: #fc8181;
      color: #fff;
    }

    /* ---- 件数表示 ---- */
    .count {
      font-size: 0.875rem;
      color: #718096;
      text-align: right;
    }

    /* ---- 空状態 ---- */
    .empty-state {
      text-align: center;
      padding: 2rem 0;
      color: #a0aec0;
      font-size: 0.9rem;
    }
  </style>
</head>
<body>
  <div class="app">
    <h1>Todo アプリ</h1>

    <!-- 入力フォーム -->
    <div class="input-area">
      <input
        type="text"
        class="todo-input"
        id="todoInput"
        placeholder="新しい Todo を入力..."
        autocomplete="off"
      />
      <button class="add-btn" id="addBtn">追加</button>
    </div>

    <!-- フィルタータブ -->
    <div class="filters">
      <button class="filter-btn active" data-filter="all">すべて</button>
      <button class="filter-btn" data-filter="active">未完了</button>
      <button class="filter-btn" data-filter="completed">完了済み</button>
    </div>

    <!-- Todo リスト(JSで動的に生成する) -->
    <ul class="todo-list" id="todoList"></ul>

    <!-- 件数表示 -->
    <p class="count" id="count"></p>
  </div>

  <script src="app.js"></script>
</body>
</html>

HTML の骨格は完成です。<ul id="todoList"> は今は空ですが、JavaScript が動的に <li> 要素を追加していきます。


データ配列を用意する

PHP/Laravel では、データをデータベースに保存してサーバーサイドで管理します。バニラ JavaScript のフロントエンドでは、JavaScript の配列(変数)がデータの「唯一の真実(Single Source of Truth)」 になります。

app.js ファイルを作成して、以下のコードから始めましょう。

// app.js

// ---- データの定義 ----

// todos 配列がアプリの「状態(State)」
// 画面に表示するデータはすべてここに格納する
let todos = [];

// フィルターの現在の状態
let currentFilter = "all"; // "all" | "active" | "completed"

// ---- Todo の形(オブジェクトの構造) ----
// 各 Todo は以下のプロパティを持つ:
// {
//   id: number        — 一意の識別子(Date.now() で生成)
//   text: string      — Todo のテキスト内容
//   completed: boolean — 完了状態(true = 完了, false = 未完了)
// }
📝 データ配列が「状態(State)」

この todos 配列が、アプリの「状態(State)」です。画面に何を表示するかは、すべてこの配列の内容によって決まります。

PHP で言えば、コントローラーからビューに渡す $todos 変数のようなものです。ただし、フロントエンドでは データが変わるたびに自分で画面を更新する必要があります

Laravel では $todos が変わればサーバーが新しい HTML を返してくれますが、JavaScript では データと画面の同期を自分でコードとして書かなければなりません。これが後で React を使う大きな理由になります。


render 関数パターン

これがこのチャプターで最も重要なパターンです。データ(配列)を元に DOM を全部作り直す という考え方です。

なぜ「全部作り直す」のか?

一つの Todo を更新したとき、対応する <li> 要素だけを探して変更することもできます。しかし、それだと:

…と、操作ごとに異なる DOM 操作コードが必要になります。アプリが複雑になるほど、このコードの管理が難しくなります。

render 関数パターン では、データが変わるたびに リスト全体を作り直します。コードはシンプルになり、「データ → 画面」の変換ロジックが一箇所にまとまります。

// app.js(続き)

// ---- DOM 要素の参照を取得 ----
const todoListEl = document.getElementById("todoList");
const countEl = document.getElementById("count");
const todoInputEl = document.getElementById("todoInput");

// ---- フィルタリング済みの todos を返すヘルパー ----
function getFilteredTodos() {
  if (currentFilter === "active") {
    // 未完了の Todo だけ返す
    return todos.filter((todo) => !todo.completed);
  }
  if (currentFilter === "completed") {
    // 完了済みの Todo だけ返す
    return todos.filter((todo) => todo.completed);
  }
  // "all" の場合はそのまま返す
  return todos;
}

// ---- メインの render 関数 ----
// データが変わるたびにこの関数を呼ぶ
function render() {
  // フィルタリングされた Todo の配列を取得
  const filteredTodos = getFilteredTodos();

  // リストを一度完全にクリアする
  // これが「全部作り直す」パターンの核心
  todoListEl.innerHTML = "";

  // Todo が 0 件のとき、空状態メッセージを表示する
  if (filteredTodos.length === 0) {
    todoListEl.innerHTML = `
      <li class="empty-state">
        ${currentFilter === "completed" ? "完了済みの Todo はありません" : "Todo はありません"}
      </li>
    `;
  } else {
    // filteredTodos 配列を順にループし、DOM 要素を生成する
    filteredTodos.forEach((todo) => {
      // <li> 要素を作成
      const li = document.createElement("li");
      li.className = "todo-item";
      // data-id 属性に todo の id を記録しておく(後でイベント処理に使う)
      li.dataset.id = todo.id;

      // <li> の内側の HTML を生成
      li.innerHTML = `
        <input
          type="checkbox"
          class="todo-checkbox"
          ${todo.completed ? "checked" : ""}
        />
        <span class="todo-text ${todo.completed ? "completed" : ""}">
          ${escapeHtml(todo.text)}
        </span>
        <button class="delete-btn">削除</button>
      `;

      // 完成した <li> をリストに追加
      todoListEl.appendChild(li);
    });
  }

  // 未完了の件数を計算して表示する
  const activeCount = todos.filter((t) => !t.completed).length;
  countEl.textContent = `未完了: ${activeCount}`;

  // フィルターボタンのアクティブ状態を更新する
  document.querySelectorAll(".filter-btn").forEach((btn) => {
    if (btn.dataset.filter === currentFilter) {
      btn.classList.add("active");
    } else {
      btn.classList.remove("active");
    }
  });
}

// ---- XSS 対策:ユーザー入力を安全に表示するためのエスケープ関数 ----
function escapeHtml(str) {
  const div = document.createElement("div");
  div.appendChild(document.createTextNode(str));
  return div.innerHTML;
}
💡 これが React の基本的な考え方

この「データを元に DOM を全部作り直す」パターンが、React の基本的な考え方です。

React はこれを自動的に、かつ効率的にやってくれます。React の Virtual DOM は「何が変わったか」を計算して、本当に変わった部分だけ 実際の DOM に反映します。そのため、毎回全部作り直しても高速に動作します。

バニラ JS でこのパターンを手書きすることで、React がどんな問題を解決しているかが実感できます。


Todo を追加する

フォームの送信イベントを受け取り、新しい Todo オブジェクトを作って配列に追加します。

// app.js(続き)

// ---- Todo を追加する関数 ----
function addTodo(text) {
  // 入力が空白だけの場合は何もしない
  const trimmedText = text.trim();
  if (trimmedText === "") return;

  // 新しい Todo オブジェクトを作成
  const newTodo = {
    id: Date.now(),       // 現在のタイムスタンプを ID として使用
    text: trimmedText,    // トリムしたテキスト
    completed: false,     // 初期状態は未完了
  };

  // todos 配列の末尾に追加
  todos.push(newTodo);

  // localStorage に保存
  saveTodos();

  // 画面を更新
  render();

  // 入力フィールドをクリア
  todoInputEl.value = "";
  todoInputEl.focus(); // 次の入力がしやすいようにフォーカスを戻す
}

// ---- 追加ボタンのイベントリスナー ----
document.getElementById("addBtn").addEventListener("click", () => {
  addTodo(todoInputEl.value);
});

// ---- Enter キーでも追加できるようにする ----
todoInputEl.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    addTodo(todoInputEl.value);
  }
});

PHP/Laravel で $todos[] = $newTodo; してからリダイレクトするのに似ていますが、JavaScript ではページ遷移なしに即座に画面が更新されます。


完了の切り替え

チェックボックスをクリックしたとき、対応する Todo の completed フラグを反転させます。ここでは イベントデリゲーション というパターンを使います。

イベントデリゲーションとは?

個々のチェックボックスにイベントリスナーを付けるのではなく、親要素(<ul>)にひとつだけリスナーを付け、どの子要素がクリックされたか判断します。

なぜかというと、Todo は動的に追加・削除されるため、個別にリスナーを管理すると複雑になるからです。

// app.js(続き)

// ---- イベントデリゲーション:リスト全体のクリックを監視 ----
todoListEl.addEventListener("click", (event) => {
  // クリックされた要素を取得
  const target = event.target;

  // 一番近い .todo-item 要素(<li>)を探す
  const todoItem = target.closest(".todo-item");
  if (!todoItem) return; // .todo-item の外をクリックした場合は無視

  // <li> の data-id から Todo の ID を取得(文字列なので数値に変換)
  const todoId = Number(todoItem.dataset.id);

  // ---- チェックボックスがクリックされた場合 ----
  if (target.classList.contains("todo-checkbox")) {
    toggleTodo(todoId);
  }

  // ---- 削除ボタンがクリックされた場合 ----
  if (target.classList.contains("delete-btn")) {
    deleteTodo(todoId);
  }
});

// ---- Todo の完了状態を切り替える関数 ----
function toggleTodo(id) {
  // todos 配列の中から対象の Todo を探す
  const todo = todos.find((t) => t.id === id);
  if (!todo) return; // 見つからなければ何もしない

  // completed フラグを反転させる(true → false, false → true)
  todo.completed = !todo.completed;

  // localStorage に保存
  saveTodos();

  // 画面を更新
  render();
}
📝 イベントデリゲーションと PHP のルーティングの類似性

イベントデリゲーションは、Laravel のルーティングに少し似ています。Laravel では routes/web.php で「どの URL にどの処理をするか」を一箇所で定義します。イベントデリゲーションでは「どの要素がクリックされたかによって、どの処理をするか」を一箇所で定義します。

target.closest(".todo-item") は、クリックされた要素から「最も近い .todo-item 先祖要素」を探します。削除ボタンのアイコンなど、子要素がクリックされた場合でも正しく <li> を特定できます。


Todo を削除する

削除は Array.filter() を使って、対象の Todo を除いた新しい配列を作ります。

// app.js(続き)

// ---- Todo を削除する関数 ----
function deleteTodo(id) {
  // 指定した id 以外の Todo だけを残した新しい配列を作る
  // Array.filter は元の配列を変更せず、新しい配列を返す
  todos = todos.filter((t) => t.id !== id);

  // localStorage に保存
  saveTodos();

  // 画面を更新
  render();
}

PHP で言えば $todos = array_filter($todos, fn($t) => $t['id'] !== $id); に相当します。


フィルター機能

「すべて」「未完了」「完了済み」のタブを切り替えることで、表示する Todo を絞り込みます。

// app.js(続き)

// ---- フィルターボタンのイベントリスナー ----
document.querySelectorAll(".filter-btn").forEach((btn) => {
  btn.addEventListener("click", () => {
    // クリックされたボタンの data-filter 属性値を取得
    // ("all" | "active" | "completed")
    currentFilter = btn.dataset.filter;

    // 画面を更新(render 内でフィルタリングとボタンのアクティブ状態更新が行われる)
    render();
  });
});

フィルタリングのロジックは先に定義した getFilteredTodos() 関数にまとめてあります。render()getFilteredTodos() を呼び出して、現在のフィルターに合った Todo だけを表示します。

currentFilter という変数が「今どのフィルタータブが選択されているか」という状態を保持しています。todos 配列と同じく、これも アプリの状態(State) の一部です。


localStorage で永続化

ページをリロードしても Todo が消えないように、localStorage にデータを保存します。

localStorage は、ブラウザに紐づいたキーバリューのストレージです。文字列しか保存できないので、JSON.stringify() でオブジェクトを文字列に変換し、読み込むときは JSON.parse() で元に戻します。

// app.js(続き)

// ---- localStorage に todos を保存する関数 ----
function saveTodos() {
  // todos 配列を JSON 文字列に変換して保存
  // 例: '[{"id":1234,"text":"牛乳を買う","completed":false}]'
  localStorage.setItem("todos", JSON.stringify(todos));
}

// ---- localStorage から todos を読み込む関数 ----
function loadTodos() {
  const saved = localStorage.getItem("todos");
  if (saved) {
    // JSON 文字列をパースして todos 配列に戻す
    todos = JSON.parse(saved);
  }
  // saved が null の場合(初回アクセス)は todos = [] のまま
}

// ---- アプリの初期化 ----
// ページ読み込み時に localStorage からデータを復元し、画面を描画する
loadTodos();
render();
📝 localStorage の注意点

localStorage はブラウザごと、オリジン(ドメイン+ポート)ごとに独立しています。Safari のプライベートブラウジングモードや、ブラウザの設定によっては使えない場合があります。また、容量の上限(一般的に 5MB 程度)もあります。

実際のプロダクションアプリでは、サーバーの API にデータを送信して データベースに保存しますが、学習目的のフロントエンドオンリーアプリでは localStorage が便利です。


完成コード全体

これまで分割して書いてきたコードをひとつの app.js ファイルにまとめます。

// app.js — Todo アプリ 完成版

// ============================================================
// 1. 状態(State)の定義
// ============================================================

let todos = []; // Todo の配列(アプリのデータ)
let currentFilter = "all"; // 現在のフィルター

// ============================================================
// 2. DOM 要素への参照
// ============================================================

const todoListEl = document.getElementById("todoList");
const countEl = document.getElementById("count");
const todoInputEl = document.getElementById("todoInput");

// ============================================================
// 3. ユーティリティ関数
// ============================================================

// XSS 対策:ユーザー入力をエスケープする
function escapeHtml(str) {
  const div = document.createElement("div");
  div.appendChild(document.createTextNode(str));
  return div.innerHTML;
}

// 現在のフィルターに基づいて Todo 配列をフィルタリングする
function getFilteredTodos() {
  if (currentFilter === "active") {
    return todos.filter((t) => !t.completed);
  }
  if (currentFilter === "completed") {
    return todos.filter((t) => t.completed);
  }
  return todos;
}

// ============================================================
// 4. localStorage の操作
// ============================================================

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function loadTodos() {
  const saved = localStorage.getItem("todos");
  if (saved) {
    todos = JSON.parse(saved);
  }
}

// ============================================================
// 5. render 関数(データ → DOM の変換)
// ============================================================

function render() {
  const filteredTodos = getFilteredTodos();

  // リストをクリア
  todoListEl.innerHTML = "";

  if (filteredTodos.length === 0) {
    // 空状態の表示
    todoListEl.innerHTML = `
      <li class="empty-state">
        ${currentFilter === "completed" ? "完了済みの Todo はありません" : "Todo はありません"}
      </li>
    `;
  } else {
    // 各 Todo の DOM 要素を生成
    filteredTodos.forEach((todo) => {
      const li = document.createElement("li");
      li.className = "todo-item";
      li.dataset.id = todo.id;
      li.innerHTML = `
        <input
          type="checkbox"
          class="todo-checkbox"
          ${todo.completed ? "checked" : ""}
        />
        <span class="todo-text ${todo.completed ? "completed" : ""}">
          ${escapeHtml(todo.text)}
        </span>
        <button class="delete-btn">削除</button>
      `;
      todoListEl.appendChild(li);
    });
  }

  // 件数を更新
  const activeCount = todos.filter((t) => !t.completed).length;
  countEl.textContent = `未完了: ${activeCount}`;

  // フィルターボタンのアクティブ状態を更新
  document.querySelectorAll(".filter-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.filter === currentFilter);
  });
}

// ============================================================
// 6. CRUD 操作
// ============================================================

// Todo を追加する
function addTodo(text) {
  const trimmedText = text.trim();
  if (trimmedText === "") return;

  todos.push({
    id: Date.now(),
    text: trimmedText,
    completed: false,
  });

  saveTodos();
  render();
  todoInputEl.value = "";
  todoInputEl.focus();
}

// Todo の完了状態を切り替える
function toggleTodo(id) {
  const todo = todos.find((t) => t.id === id);
  if (!todo) return;
  todo.completed = !todo.completed;
  saveTodos();
  render();
}

// Todo を削除する
function deleteTodo(id) {
  todos = todos.filter((t) => t.id !== id);
  saveTodos();
  render();
}

// ============================================================
// 7. イベントリスナー
// ============================================================

// 追加ボタン
document.getElementById("addBtn").addEventListener("click", () => {
  addTodo(todoInputEl.value);
});

// Enter キー
todoInputEl.addEventListener("keydown", (e) => {
  if (e.key === "Enter") addTodo(todoInputEl.value);
});

// リスト内のクリック(イベントデリゲーション)
todoListEl.addEventListener("click", (e) => {
  const todoItem = e.target.closest(".todo-item");
  if (!todoItem) return;
  const id = Number(todoItem.dataset.id);

  if (e.target.classList.contains("todo-checkbox")) {
    toggleTodo(id);
  }
  if (e.target.classList.contains("delete-btn")) {
    deleteTodo(id);
  }
});

// フィルタータブ
document.querySelectorAll(".filter-btn").forEach((btn) => {
  btn.addEventListener("click", () => {
    currentFilter = btn.dataset.filter;
    render();
  });
});

// ============================================================
// 8. アプリの初期化
// ============================================================

loadTodos(); // localStorage からデータを読み込む
render();    // 初期表示
⚠️ コードの量と複雑さを実感してください

この app.js は約 120 行になりました。Todo アプリという比較的シンプルなアプリでも、これだけのコードが必要です。

特に注目してほしいのは以下の点です:

  1. render() を手動で呼び出しているaddTodo, toggleTodo, deleteTodo のすべてで、最後に render() を呼ばなければなりません。もし呼び忘れると、データは変わっているのに画面が更新されないバグが起きます。

  2. DOM を毎回作り直しているtodoListEl.innerHTML = "" で全消去して、ループで全部作り直しています。

  3. 状態が複数の変数に分散しているtodoscurrentFilter の 2 つの状態変数があり、これらが連動して動作します。

このパターンの理解が、React を学ぶ準備になります。React はこれらをもっとシンプルに書けるようにしてくれます。


コードの振り返り

完成したアプリを振り返って、バニラ JavaScript の DOM 操作の特徴と課題を整理します。

(a) データが変わるたびに render() を呼ぶ必要がある

// 追加、切り替え、削除のすべてで render() を呼んでいる
todos.push(newTodo);
saveTodos();
render(); // ← 忘れると画面が更新されない!

todo.completed = !todo.completed;
saveTodos();
render(); // ← ここでも忘れない

todos = todos.filter((t) => t.id !== id);
saveTodos();
render(); // ← ここでも

(b) DOM 生成コードが冗長で読みにくい

// <li> 一つ作るだけでこれだけのコードが必要
const li = document.createElement("li");
li.className = "todo-item";
li.dataset.id = todo.id;
li.innerHTML = `
  <input type="checkbox" class="todo-checkbox" ${todo.completed ? "checked" : ""} />
  <span class="todo-text ${todo.completed ? "completed" : ""}">${escapeHtml(todo.text)}</span>
  <button class="delete-btn">削除</button>
`;
todoListEl.appendChild(li);

(c) イベントデリゲーションが複雑

// 「どのボタンが押されたか」を自分で判定しなければならない
todoListEl.addEventListener("click", (e) => {
  const todoItem = e.target.closest(".todo-item");
  if (!todoItem) return;
  const id = Number(todoItem.dataset.id);
  if (e.target.classList.contains("todo-checkbox")) { toggleTodo(id); }
  if (e.target.classList.contains("delete-btn")) { deleteTodo(id); }
});

(d) 状態と UI の同期を手動で管理している

データ(todos, currentFilter)と画面(DOM)が自動的に連動するわけではありません。データを変えたら必ず render() を呼ぶというルールを、開発者が守り続けなければなりません。

React はこれらの問題をすべて解決します。 React では状態(State)が変わると自動的に再レンダリングが起こります。DOM の生成は JSX でシンプルに書けます。イベントハンドラーは各コンポーネントに自然に紐づきます。


✍ チャレンジ:編集機能を追加する

Todo アプリに「編集」機能を追加してみましょう。以下の仕様で実装してください:

仕様:

  • Todo のテキスト部分をダブルクリックすると、<input> フィールドに変わる
  • <input> に編集後のテキストを入力して Enter キーを押すと保存される
  • Escape キーを押すとキャンセルして元のテキストに戻る
  • 空文字で保存しようとした場合はキャンセル扱いにする

ヒント:

  1. render() で各 <li> を生成するとき、テキスト <span> にダブルクリックイベントを追加する方法を考えましょう
  2. イベントデリゲーションを使う場合、dblclick イベントを todoListEl で捕捉します
  3. 「編集中の Todo の ID」を保持する変数(例: let editingId = null)を追加します
  4. render() 内で todo.id === editingId のとき、<span> の代わりに <input> を表示します

実装の流れ:

// 編集状態を管理する変数
let editingId = null;

// render() の中の todo 表示部分を以下のように変える:
// todo.id === editingId の場合 → <input> を表示
// そうでなければ → <span> を表示(ダブルクリックで editingId を設定)

// editTodo(id, newText) 関数を作る:
// todos 配列の対象 todo のテキストを更新
// editingId = null
// saveTodos()
// render()

この機能を追加することで、「編集モード」という新しい状態が必要になり、コードの複雑さがさらに増すことを体感できます。


まとめ

このチャプターでは、バニラ JavaScript で Todo アプリ(フル CRUD)を実装しました。

学んだこと

パターン説明
データ配列 = 状態todos 配列がアプリの「唯一の真実」。画面に表示するデータの元
render 関数データを元に DOM 全体を作り直す関数。データが変わるたびに呼ぶ
イベントデリゲーション親要素にイベントリスナーを付け、event.target で処理を振り分ける
localStorageJSON.stringify / JSON.parse でオブジェクトを文字列に変換して保存・復元
XSS 対策ユーザー入力を直接 innerHTML に入れない。escapeHtml などで安全に処理する

次のチャプターへ

次のチャプター「複雑な UI パターン」では、タブ切り替え、モーダルダイアログ、リアルタイム検索など、よりリッチな UI を DOM 操作で実装します。コードがさらに複雑になっていく過程を体験し、なぜ React のようなフレームワークが生まれたのかを深く理解しましょう。