本文へスキップ
JavaScript 応用 JavaScript Todoアプリ 総合演習

Vanilla JavaScriptでTodoアプリを作ろう

更新日:2026-08-02

これまでの知識を組み合わせ、タスクの追加、完了、削除、保存ができるTodoアプリを作ります。

この総合演習では、完成コードを最初から貼り付けるのではなく、状態、描画、イベント、保存の順に組み立てます。各段階で動作を確認し、どこまでできているかを切り分けながら進めてください。

この記事を終えると、次のことができるようになります。

  • アプリが扱うデータを配列とオブジェクトで設計できる
  • 状態からHTML一覧を描画できる
  • フォームとイベント委譲で状態を変更できる
  • localStorageへ保存し、再読み込み後に復元できる
  • 空表示、件数、入力エラーを利用者へ伝えられる

今回使用するファイル構成

todo-app/
├── index.html
├── css/
│   └── style.css
└── js/
    └── script.js

Todoアプリを組み立てる

HTMLを用意する

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>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <main class="todo-app">
    <h1>学習Todo</h1>

    <form id="todo-form" novalidate>
      <label for="todo-title">新しいタスク</label>
      <div class="todo-form-row">
        <input id="todo-title" type="text" maxlength="100"
          aria-describedby="todo-error">
        <button type="submit">追加する</button>
      </div>
    </form>

    <p id="todo-error" class="error-message" aria-live="polite"></p>

    <div class="todo-summary">
      <p id="todo-count">未完了0件/全0件</p>
      <button id="clear-completed" type="button">完了済みを削除</button>
    </div>

    <p id="empty-message">タスクはまだありません。</p>
    <ul id="todo-list"></ul>
  </main>

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

maxlengthは入力できる最大文字数をブラウザ側で制限します。JavaScriptでも同じ条件を検証し、データとして追加されないようにします。

最低限の見た目を整える

css/style.cssへ記述します。

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 2rem 1rem;
  color: #222;
  background: #f5f7fa;
  font-family: sans-serif;
}

.todo-app {
  width: min(100%, 42rem);
  margin-inline: auto;
  padding: 2rem;
  background: #fff;
  border-radius: 0.75rem;
}

.todo-form-row,
.todo-summary,
.todo-item {
  display: flex;
  gap: 0.75rem;
  align-items: center;
}

.todo-form-row input {
  flex: 1;
  min-width: 0;
}

.todo-summary {
  justify-content: space-between;
  margin-block: 1.5rem 1rem;
}

#todo-list {
  display: grid;
  gap: 0.75rem;
  padding: 0;
  list-style: none;
}

.todo-item {
  padding: 0.75rem;
  border: 1px solid #ccc;
  border-radius: 0.5rem;
}

.todo-title {
  flex: 1;
}

.todo-item.is-completed .todo-title {
  color: #666;
  text-decoration: line-through;
}

.error-message {
  min-height: 1.5em;
  color: #b00020;
}

JavaScriptを書く前にブラウザで開き、入力欄、ボタン、空状態が表示されることを確認します。

状態を配列で管理する

const STORAGE_KEY = 'buildbooks.todos';

function loadTodos() {
  const savedTodos = localStorage.getItem(STORAGE_KEY);

  if (savedTodos === null) {
    return [];
  }

  try {
    const parsedTodos = JSON.parse(savedTodos);
    return Array.isArray(parsedTodos) ? parsedTodos : [];
  } catch (error) {
    console.error('Todoを復元できませんでした', error);
    return [];
  }
}

let todos = loadTodos();

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

todosがアプリの現在状態です。画面上のli要素を元データとして扱わず、配列を正しい情報源にします。

保存データが壊れていても画面全体を停止させないよう、JSON.parse()の失敗を処理しています。DevToolsから値を変更できるため、保存データを無条件には信用しません。

各TodoはID、タイトル、完了状態を持つオブジェクトにします。

function createTodo(title) {
  return {
    id: crypto.randomUUID(),
    title,
    completed: false,
  };
}

crypto.randomUUID()で、各タスクを区別するIDを作ります。配列の番号をIDにすると、削除や並べ替えによって番号が変わるため、データ自身に変更されないIDを持たせます。

一覧を描画する

最初に必要な要素を取得します。

const form = document.querySelector('#todo-form');
const titleInput = document.querySelector('#todo-title');
const errorMessage = document.querySelector('#todo-error');
const todoCount = document.querySelector('#todo-count');
const emptyMessage = document.querySelector('#empty-message');
const todoList = document.querySelector('#todo-list');
const clearCompletedButton = document.querySelector('#clear-completed');

一件分の要素生成を関数へ分けます。

function createTodoElement(todo) {
  const item = document.createElement('li');
  const checkbox = document.createElement('input');
  const title = document.createElement('span');
  const removeButton = document.createElement('button');

  item.classList.add('todo-item');
  item.classList.toggle('is-completed', todo.completed);
  item.dataset.id = todo.id;

  checkbox.type = 'checkbox';
  checkbox.checked = todo.completed;
  checkbox.setAttribute('aria-label', `${todo.title}を完了にする`);

  title.classList.add('todo-title');
  title.textContent = todo.title;

  removeButton.type = 'button';
  removeButton.textContent = '削除';
  removeButton.dataset.action = 'remove';
  removeButton.setAttribute('aria-label', `${todo.title}を削除`);

  item.append(checkbox, title, removeButton);
  return item;
}

利用者が入力したタイトルはtextContentへ設定します。innerHTMLへ渡さないため、入力文字列がHTMLとして解釈されません。

function renderTodos() {
  todoList.replaceChildren();

  todos.forEach((todo) => {
    todoList.append(createTodoElement(todo));
  });

  const incompleteCount = todos.filter((todo) => !todo.completed).length;
  todoCount.textContent = `未完了${incompleteCount}件/全${todos.length}件`;
  emptyMessage.hidden = todos.length > 0;
  clearCompletedButton.disabled = !todos.some((todo) => todo.completed);
}

描画するたびに、一覧だけでなく件数、空メッセージ、完了済み削除ボタンも現在状態へ同期します。

追加・完了・削除を実装する

form.addEventListener('submit', (event) => {
  event.preventDefault();

  const title = titleInput.value.trim();

  if (title === '') {
    errorMessage.textContent = 'タスクを入力してください。';
    titleInput.focus();
    return;
  }

  if (title.length > 100) {
    errorMessage.textContent = 'タスクは100文字以内で入力してください。';
    titleInput.focus();
    return;
  }

  todos.push(createTodo(title));
  saveTodos();
  renderTodos();

  errorMessage.textContent = '';
  form.reset();
  titleInput.focus();
});

todoList.addEventListener('change', (event) => {
  if (!event.target.matches('input[type="checkbox"]')) {
    return;
  }

  const item = event.target.closest('.todo-item');
  const todo = todos.find((todo) => todo.id === item.dataset.id);

  if (!todo) {
    return;
  }

  todo.completed = event.target.checked;
  saveTodos();
  renderTodos();
});

todoList.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action="remove"]');

  if (!button) {
    return;
  }

  const item = button.closest('.todo-item');
  todos = todos.filter((todo) => todo.id !== item.dataset.id);
  saveTodos();
  renderTodos();
});

clearCompletedButton.addEventListener('click', () => {
  todos = todos.filter((todo) => !todo.completed);
  saveTodos();
  renderTodos();
});

renderTodos();

完了と削除は一覧の親要素で受け取るイベント委譲です。操作対象のliが持つdata-idと配列内のIDを対応させています。

すべての状態変更後に、saveTodos()renderTodos()を同じ順序で呼びます。

利用者の操作
↓
todos配列を変更
↓
localStorageへ保存
↓
現在の配列から画面を再描画

この一方向の流れを守ると、DOMと保存データのどちらが正しいのか分からなくなる状態を避けられます。

段階ごとに動作確認する

追加機能を確認する

  1. 通常の文章を追加できる
  2. 空文字と空白だけでは追加できない
  3. 追加後に入力欄が空になり、フォーカスが戻る
  4. 件数と空メッセージが更新される

完了と削除を確認する

  1. チェックすると取り消し線が付く
  2. 未完了件数が減る
  3. 再度チェックを外すと未完了へ戻る
  4. 任意の一件だけを削除できる
  5. 完了済みをまとめて削除できる

保存と復元を確認する

  1. 複数件を追加する
  2. 一部を完了にする
  3. ページを再読み込みする
  4. タイトルと完了状態が復元される
  5. DevToolsのLocal StorageでJSONを確認する

うまく動かないときの確認点

追加後に同じ項目が重複する

renderTodos()の最初でreplaceChildren()を呼んでいるか確認します。古いDOMを消してから、現在の配列をすべて描画します。

チェックしても状態が戻る

対象のTodoを配列から探し、completedを変更してから保存しているか確認します。DOMのクラスだけを変更しても保存データは変わりません。

再読み込みすると消える

状態変更後にsaveTodos()を呼んでいるか、保存キーが取得時と一致しているかを確認します。DevToolsのConsoleとApplicationタブも確認します。

JSON.parseで画面が止まる

保存データが壊れている可能性があります。trycatchで処理し、必要に応じてLocal Storageから該当キーを削除します。

crypto.randomUUID is not a functionと表示される

古いブラウザや安全でない接続では利用できない場合があります。Live Serverのlocalhostで確認し、対象ブラウザに応じてID生成方法を用意します。

完成した機能を検証する

動作を確認する

追加、空入力、完了切り替え、削除、再読み込み後の復元、キーボード操作を確認します。マウスを使わず、Tab、Shift+Tab、Enter、Spaceキーだけでも一連の操作を行ってください。最後にConsoleへエラーがないことを確認します。

まとめ

Todoアプリでは、状態を配列で管理し、状態変更後に保存と描画を行いました。この流れを理解すると、Alpine.jsやVue.jsが状態と表示の同期をどのように支援するのか理解しやすくなります。

ライブラリやフレームワークを学ぶときも、今回のtodosrenderTodos()、イベント処理がそれぞれ何に置き換わるかを考えると、便利さの理由を理解できます。まずは完成コードを見ずに、空のファイルからもう一度Todoアプリを組み立ててみてください。

次は「サーバー・インフラ」カテゴリへ進み、PHPを実行するためのWSL 2、Linux、Dockerの開発環境を準備します。