Todo App を作ろう
バニラ JavaScript でフル CRUD の Todo アプリを作り、DOM 操作の実践力を鍛えます。
このチャプターで学ぶこと
- 配列でデータを管理し、DOM に反映するパターンを理解できる
- 追加・完了切り替え・削除の CRUD 操作を実装できる
- データ配列と DOM 表示を同期させるrenderパターンを使える
- localStorage でデータを永続化できる
- DOM 操作コードの量と複雑さを実感できる
完成形の確認
このチャプターでは、バニラ JavaScript だけを使って本格的な Todo アプリを作ります。完成形のアプリは以下の機能を持ちます。
- 入力フィールドと追加ボタン — テキストを入力して「追加」ボタンを押すか Enter キーを押すと Todo が追加される
- Todo リスト — 各 Todo にはチェックボックス、テキスト、削除ボタンがある
- 完了状態の切り替え — チェックボックスをクリックすると完了状態になり、テキストに打ち消し線が入る
- 削除機能 — 削除ボタンをクリックすると Todo が消える
- フィルタータブ — 「すべて」「未完了」「完了済み」の 3 つのタブで Todo を絞り込める
- 件数表示 — 未完了の Todo の件数を表示する
- データの永続化 — ページをリロードしても Todo が消えない(localStorage 使用)
このアプリを完成させることで、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 = 未完了)
// }
この todos 配列が、アプリの「状態(State)」です。画面に何を表示するかは、すべてこの配列の内容によって決まります。
PHP で言えば、コントローラーからビューに渡す $todos 変数のようなものです。ただし、フロントエンドでは データが変わるたびに自分で画面を更新する必要があります。
Laravel では $todos が変わればサーバーが新しい HTML を返してくれますが、JavaScript では データと画面の同期を自分でコードとして書かなければなりません。これが後で React を使う大きな理由になります。
render 関数パターン
これがこのチャプターで最も重要なパターンです。データ(配列)を元に DOM を全部作り直す という考え方です。
なぜ「全部作り直す」のか?
一つの Todo を更新したとき、対応する <li> 要素だけを探して変更することもできます。しかし、それだと:
- 追加のときは
<li>を末尾に追加する処理 - 削除のときは対象の
<li>を探して消す処理 - 更新のときは対象の
<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;
}
この「データを元に 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();
}
イベントデリゲーションは、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 はブラウザごと、オリジン(ドメイン+ポート)ごとに独立しています。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 アプリという比較的シンプルなアプリでも、これだけのコードが必要です。
特に注目してほしいのは以下の点です:
-
render()を手動で呼び出している —addTodo,toggleTodo,deleteTodoのすべてで、最後にrender()を呼ばなければなりません。もし呼び忘れると、データは変わっているのに画面が更新されないバグが起きます。 -
DOM を毎回作り直している —
todoListEl.innerHTML = ""で全消去して、ループで全部作り直しています。 -
状態が複数の変数に分散している —
todosとcurrentFilterの 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 キーを押すとキャンセルして元のテキストに戻る
- 空文字で保存しようとした場合はキャンセル扱いにする
ヒント:
render()で各<li>を生成するとき、テキスト<span>にダブルクリックイベントを追加する方法を考えましょう- イベントデリゲーションを使う場合、
dblclickイベントをtodoListElで捕捉します - 「編集中の Todo の ID」を保持する変数(例:
let editingId = null)を追加します 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 で処理を振り分ける |
| localStorage | JSON.stringify / JSON.parse でオブジェクトを文字列に変換して保存・復元 |
| XSS 対策 | ユーザー入力を直接 innerHTML に入れない。escapeHtml などで安全に処理する |
次のチャプターへ
次のチャプター「複雑な UI パターン」では、タブ切り替え、モーダルダイアログ、リアルタイム検索など、よりリッチな UI を DOM 操作で実装します。コードがさらに複雑になっていく過程を体験し、なぜ React のようなフレームワークが生まれたのかを深く理解しましょう。