JavaScript実践 #6 小さなアプリのビルド — Todo アプリ

読了 6分

JavaScript トラックの最後の記事です。これまで扱ってきた道具 — DOM、イベント委譲、FormData、ローカルストレージ、軽量 store — をまとめてライブラリなしで Todo アプリを最初から作ってみます。

作るもの — 要件 #

  • Todo を入力して追加
  • 項目をクリックすると完了 / 未完了をトグル
  • 削除ボタンで除去
  • フィルター — すべて / 進行中 / 完了
  • 再読み込み後もデータ保持(localStorage)
  • 別のタブとも同期

ライブラリ 0 個。ビルドツールなしで HTML + JS の 2 ファイルで十分です。

ステップ 1 — HTML の骨組み #

index.html
<html lang="ko">
  <head>
    <meta charset="UTF-8">
    <title>Todo</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <main class="app">
      <h1>Todo</h1>

      <form id="add-form">
        <input
          name="text"
          placeholder="やることを入力してください"
          required
          autofocus
        >
        <button type="submit">追加</button>
      </form>

      <div class="filters">
        <button data-filter="all" class="active">すべて</button>
        <button data-filter="active">進行中</button>
        <button data-filter="done">完了</button>
      </div>

      <ul id="todo-list"></ul>

      <p class="stats" id="stats"></p>
    </main>

    <script type="module" src="main.js"></script>
  </body>
</html>

ポイントは二つ。

  1. <form required>#4 フォームの扱い のビルトイン検証を活用
  2. <script type="module">基礎 #7 モジュール

ステップ 2 — store を作る #

まずデータ構造と状態管理を分離して作ります。

store.js
// 状態の形
// {
//   todos: [{ id: string, text: string, done: boolean, createdAt: number }],
//   filter: 'all' | 'active' | 'done',
// }

const KEY = 'todo-app';

function load() {
  try {
    const raw = localStorage.getItem(KEY);
    if (raw === null) return { todos: [], filter: 'all' };
    return JSON.parse(raw);
  } catch {
    return { todos: [], filter: 'all' };
  }
}

function save(state) {
  try {
    localStorage.setItem(KEY, JSON.stringify(state));
  } catch (err) {
    console.warn('save 失敗:', err);
  }
}

export function createStore() {
  let state = load();
  const listeners = new Set();

  function notify() {
    listeners.forEach((fn) => fn(state));
  }

  function set(updater) {
    state = typeof updater === 'function' ? updater(state) : updater;
    save(state);
    notify();
  }

  // 他のタブの変更を同期
  window.addEventListener('storage', (e) => {
    if (e.key !== KEY || e.newValue === null) return;
    try {
      state = JSON.parse(e.newValue);
      notify();
    } catch {}
  });

  return {
    get: () => state,
    set,
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

#5 で作ったパターンそのまま。永続化 + マルチタブ同期まで一つのモジュールに整理。

ステップ 3 — アクション関数 #

状態を直接 set するよりも、意図が明確なアクション関数を作っておくとコードが整います。

actions.js
import { createStore } from './store.js';

export const store = createStore();

export const actions = {
  add(text) {
    if (!text.trim()) return;
    store.set((s) => ({
      ...s,
      todos: [
        ...s.todos,
        {
          id: crypto.randomUUID(),
          text: text.trim(),
          done: false,
          createdAt: Date.now(),
        },
      ],
    }));
  },
  toggle(id) {
    store.set((s) => ({
      ...s,
      todos: s.todos.map((t) =>
        t.id === id ? { ...t, done: !t.done } : t
      ),
    }));
  },
  remove(id) {
    store.set((s) => ({
      ...s,
      todos: s.todos.filter((t) => t.id !== id),
    }));
  },
  setFilter(filter) {
    store.set((s) => ({ ...s, filter }));
  },
};

crypto.randomUUID() がビルトインなので、ライブラリなしで ID を作れます(最近のブラウザ / Node 共にサポート)。

ステップ 4 — レンダ関数 #

状態を画面に描く一方向の流れを作ります。

ui.js
const list = document.querySelector('#todo-list');
const stats = document.querySelector('#stats');
const filterBtns = document.querySelectorAll('[data-filter]');

function filterTodos(todos, filter) {
  if (filter === 'active') return todos.filter((t) => !t.done);
  if (filter === 'done') return todos.filter((t) => t.done);
  return todos;
}

export function render(state) {
  // リスト
  const visible = filterTodos(state.todos, state.filter);

  list.innerHTML = visible.map((t) => `
    <li class="todo ${t.done ? 'done' : ''}" data-id="${t.id}">
      <input type="checkbox" ${t.done ? 'checked' : ''}>
      <span class="text">${escapeHtml(t.text)}</span>
      <button class="remove" aria-label="削除">✕</button>
    </li>
  `).join('');

  // 統計
  const total = state.todos.length;
  const done = state.todos.filter((t) => t.done).length;
  stats.textContent = total === 0
    ? '項目がありません'
    : `${done} / ${total} 完了`;

  // フィルタボタンの活性化
  filterBtns.forEach((btn) => {
    btn.classList.toggle('active', btn.dataset.filter === state.filter);
  });
}

// XSS 防止 — ユーザー入力を innerHTML に入れるときは必須
function escapeHtml(s) {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

escapeHtml がポイントです。実践 #1 で見た XSS の落とし穴 — ユーザー入力に <script> のようなものが含まれているケースに備えて、基本的な escape を常に通します。

ステップ 5 — イベントハンドラ(イベント委譲) #

リスト項目ごとにハンドラを付けるのではなく、親一箇所に委譲します。

main.js
import { store, actions } from './actions.js';
import { render } from './ui.js';

const form = document.querySelector('#add-form');
const list = document.querySelector('#todo-list');
const filters = document.querySelector('.filters');

// 追加 — フォーム送信
form.addEventListener('submit', (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const formData = new FormData(form);
  actions.add(formData.get('text'));

  form.reset();
  form.elements.text.focus();
});

// トグル / 削除 — 委譲
list.addEventListener('click', (e) => {
  const li = e.target.closest('.todo');
  if (!li) return;

  const id = li.dataset.id;

  if (e.target.matches('.remove')) {
    actions.remove(id);
  } else if (e.target.matches('input[type="checkbox"]')) {
    actions.toggle(id);
  } else if (e.target.matches('.text')) {
    actions.toggle(id);
  }
});

// フィルタ — 委譲
filters.addEventListener('click', (e) => {
  const btn = e.target.closest('[data-filter]');
  if (!btn) return;
  actions.setFilter(btn.dataset.filter);
});

// 状態変更時にレンダ
store.subscribe(render);

// 最初の一回を描画
render(store.get());

ポイントの流れ:

  1. ユーザーがアクション → actions.xxx() を呼び出す
  2. actionsstore.set で状態変更
  3. store が listeners を呼び出す → render(state) を実行
  4. DOM 更新

一方向データフローです。React の思考モデルを小規模でそのまま踏襲しています。

ステップ 6 — CSS を一握り #

機能とは直接関係ありませんが見た目のために。

style.css
* { box-sizing: border-box; }
body {
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  margin: 0;
  background: #f5f5f5;
}
.app {
  max-width: 480px;
  margin: 40px auto;
  padding: 24px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
form {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}
input[name="text"] {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
}
.filters {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}
.filters button {
  padding: 4px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 6px;
  cursor: pointer;
}
.filters button.active {
  background: #333;
  color: white;
  border-color: #333;
}
ul {
  list-style: none;
  padding: 0;
  margin: 0 0 16px;
}
.todo {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid #eee;
}
.todo .text {
  flex: 1;
  cursor: pointer;
}
.todo.done .text {
  text-decoration: line-through;
  color: #999;
}
.remove {
  background: none;
  border: none;
  color: #c33;
  cursor: pointer;
}
.stats {
  text-align: center;
  color: #666;
  font-size: 14px;
}

動作確認 #

ブラウザで index.html を開けば — 入力して追加、クリックでトグル、× ボタンで削除、フィルター動作、再読み込み時の保持がすべて動きます。別のタブを開いて同じページにアクセスすると、片方の変更がもう片方にも即座に反映されます。

ライブラリ 0 個。コードの長さは 200 行ほどです。

さらに発展させられるところ #

この骨組みの上に乗せやすいもの:

  • 編集 — 項目テキストをダブルクリックで入力モード
  • ドラッグ並び替え — HTML5 draggable または SortableJS
  • 検索 — テキストでフィルタ
  • カテゴリ / タグ — データの形を拡張
  • 外部同期 — fetch でサーバー保存 (#3 fetch と非同期 UI)
  • Service Worker — オフライン対応

それぞれの場面で、このシリーズで扱った道具をすべて活用できます。

ライブラリが必要になる地点 #

この骨組みはどこまで通用するでしょうか。ほぼすべての小〜中規模のアプリで十分に行けます。次の地点でライブラリ(React/Vue など)の導入を考えるようになります。

  • コンポーネントが深くネストして直接 DOM 操作が複雑になるとき
  • ルーティング / SSR / コードスプリッティングが必要になるとき
  • 大きなチームが同じコードを触るとき(コンポーネントインターフェースが標準化されると協業が楽)
  • ビルド / TypeScript / テストなどのツールチェーンを豊かに使いたいとき

このトラックの学習の流れはまさにその次を指しています — JavaScript → TypeScript → React シリーズへ。道具が必要だった理由が自然と理解できるはずです。

まとめ #

この記事で扱った内容:

  • HTML + JS だけで Todo アプリを最初から最後まで
  • 一方向データフロー — アクション → store → レンダ
  • イベント委譲パターン — 親に一つのハンドラ
  • localStorage 永続化 + 別タブ同期
  • XSS escape 処理
  • ビルトインフォーム検証の活用
  • ライブラリ 0 個、ビルドツールなしで動作

JavaScript トラックを終えて #

全 4 シリーズ 27 編の流れ:

  1. 基礎(7 編) — 環境からモジュールまで (#1)
  2. 中級(7 編) — クラス、非同期、デストラクチャリング、fetch (中級 #1)
  3. 上級(7 編) — クロージャ、this、プロトタイプ、イベントループ、メモリ (上級 #1)
  4. 実践(6 編) — DOM、イベント、fetch UI、フォーム、ストレージ、小さなアプリ(このシリーズ)

ここまで掴めば JavaScript で日常的なウェブインタラクションは自由に作れますし、その上に TypeScript トラック または React トラック へ進む土台になります。道具が必要な理由を理解した状態で次のトラックを始めると、学習曲線がはるかに緩やかになります。

X