JavaScript実践 #6 小さなアプリのビルド — Todo アプリ
JavaScript トラックの最後の記事です。これまで扱ってきた道具 — DOM、イベント委譲、FormData、ローカルストレージ、軽量 store — をまとめてライブラリなしで Todo アプリを最初から作ってみます。
作るもの — 要件 #
- Todo を入力して追加
- 項目をクリックすると完了 / 未完了をトグル
- 削除ボタンで除去
- フィルター — すべて / 進行中 / 完了
- 再読み込み後もデータ保持(localStorage)
- 別のタブとも同期
ライブラリ 0 個。ビルドツールなしで HTML + JS の 2 ファイルで十分です。
ステップ 1 — 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>ポイントは二つ。
<form required>— #4 フォームの扱い のビルトイン検証を活用<script type="module">— 基礎 #7 モジュール
ステップ 2 — store を作る #
まずデータ構造と状態管理を分離して作ります。
// 状態の形
// {
// 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 するよりも、意図が明確なアクション関数を作っておくとコードが整います。
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 — レンダ関数 #
状態を画面に描く一方向の流れを作ります。
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}escapeHtml がポイントです。実践 #1 で見た XSS の落とし穴 — ユーザー入力に <script> のようなものが含まれているケースに備えて、基本的な escape を常に通します。
ステップ 5 — イベントハンドラ(イベント委譲) #
リスト項目ごとにハンドラを付けるのではなく、親一箇所に委譲します。
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());ポイントの流れ:
- ユーザーがアクション →
actions.xxx()を呼び出す actionsがstore.setで状態変更storeが listeners を呼び出す →render(state)を実行- DOM 更新
一方向データフローです。React の思考モデルを小規模でそのまま踏襲しています。
ステップ 6 — 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 編の流れ:
- 基礎(7 編) — 環境からモジュールまで (#1)
- 中級(7 編) — クラス、非同期、デストラクチャリング、fetch (中級 #1)
- 上級(7 編) — クロージャ、this、プロトタイプ、イベントループ、メモリ (上級 #1)
- 実践(6 編) — DOM、イベント、fetch UI、フォーム、ストレージ、小さなアプリ(このシリーズ)
ここまで掴めば JavaScript で日常的なウェブインタラクションは自由に作れますし、その上に TypeScript トラック または React トラック へ進む土台になります。道具が必要な理由を理解した状態で次のトラックを始めると、学習曲線がはるかに緩やかになります。