1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TodoアプリでStimulusを学ぶ

1
Posted at

Stimulus とは

Stimulus は 軽量な JavaScript フレームワークです。
「HTML に少しだけ付加情報を足す」だけで動き、Rails のようなサーバーサイドレンダリングを行うアプリケーションでも、既存のHTMLの延長として JS の機能を追加できます。

Rails界隈では知られている方も多いですが、フロント単体で触る機会はまだ少ないかもしれません。

この記事では、簡単な Todo アプリを作りながら、Stimulus の基本と便利な機能を紹介します。

今回作るアプリ

今回は Todoアプリ を作りながら、Stimulusの特徴を学んでいきます。

  • タスクの追加・完了・削除
  • タスクの一括操作(全て完了 / 全て未完了 / 完了済み削除)
  • 編集モーダル
  • 未完了・完了タスクのカウント
  • 入力バリデーションでボタンの活性/非活性切替

このアプリ1つで targets / actions / values / classes / outlets / dispatch といったStimulusの主要機能が全部登場します。

アプリイメージ

スクリーンショット 2026-01-11 5.03.05.png


実際に動かす

S3にデプロイ済みです。以下のリンクから確認できます:
Todoアプリのリンク

コードを確認したい方は GitHub にもあります:
https://github.com/chain792/stimulus-todo-app


全体のソースコード

index.html
<!DOCTYPE html>

<head>
  <meta charset="utf-8">
  <title>Stimulus Todo APP</title>

  <!-- Tailwind CSS -->
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
  <!-- Stimulus -->
  <script type="module">
    import { Application } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js";
    import TodoController from "./todo_controller.js";
    import TodoItemController from "./todo_item_controller.js";

    window.Stimulus = Application.start();
    Stimulus.register("todo", TodoController);
    Stimulus.register("todo-item", TodoItemController);
  </script>
</head>
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
  <div
    data-controller="todo"
    data-todo-todo-item-outlet="[data-controller='todo-item']"
    class="w-full max-w-md bg-white rounded-xl shadow-lg p-6 space-y-4"
  >
    <h1 class="text-2xl font-bold text-gray-800 text-center">
      Stimulus Todo APP
    </h1>

    <!-- 一括操作ボタン -->
    <div class="flex gap-2 text-sm">
      <button
        data-todo-target="bulkActions"
        data-action="todo#completeAll"
        class="px-3 py-1 rounded bg-green-500 text-white hover:bg-green-600 disabled:bg-blue-200"
      >
        全て完了
      </button>

      <button
        data-todo-target="bulkActions"
        data-action="todo#activateAll"
        class="px-3 py-1 rounded bg-yellow-500 text-white hover:bg-yellow-600 disabled:bg-gray-300"
      >
        全て未完了
      </button>

      <button
        data-todo-target="bulkActions"
        data-action="todo#clearCompleted"
        class="px-3 py-1 rounded bg-red-500 text-white hover:bg-red-600 disabled:bg-red-200"
      >
        完了を削除
      </button>
    </div>

    <!-- カウンター -->
    <p class="text-sm text-gray-600">
      未完了:
      <span data-todo-target="activeCount" class="font-semibold">0</span>
      /
      完了済み:
      <span data-todo-target="completedCount" class="font-semibold">0</span>
    </p>

    <!-- 入力エリア -->
    <div class="flex gap-2">
      <input
        type="text"
        placeholder="やることを入力"
        data-todo-target="input"
        data-action="input->todo#validate"
        class="flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
      >
      <button
        data-todo-target="createButton"
        data-action="todo#create"
        class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-blue-300"
        disabled
      >
        追加
      </button>
    </div>

    <!-- Todo一覧 -->
    <ul
      data-todo-target="list"
      class="divide-y divide-gray-200"
    ></ul>

    <!-- Todoアイテムのテンプレート -->
    <template data-todo-target="itemTemplate">
      <li
        data-controller="todo-item"
        data-action="todo-item:completed-changed->todo#updateCounts"
        data-todo-item-done-class="line-through text-gray-400 italic"
        class="flex items-center gap-3 py-3 px-2"
      >
        <input
          type="checkbox"
          data-todo-item-target="checkbox"
          data-action="change->todo-item#updateCompleted"
          class="h-4 w-4 text-blue-500 rounded focus:ring-blue-400"
        >

        <span
          data-todo-item-target="content"
          class="flex-1 text-gray-800"
        ></span>

        <button
          data-action="click->todo-item#openEditModal"
          class="px-2 py-1 text-sm b bg-yellow-500 text-white rounded hover:bg-yellow-600"
        >
          編集
        </button>

        <button
          data-action="click->todo-item#remove"
          class="px-2 py-1 text-sm text-white bg-red-500 rounded hover:bg-red-600"
        >
          削除
        </button>

        <!-- 編集モーダル -->
        <div
          data-todo-item-target="editModal"
          class="fixed inset-0 bg-black/40 flex items-center justify-center hidden"
        >
          <div class="bg-white rounded-lg p-4 w-80 space-y-3">
            <h2 class="text-lg font-semibold">タスクを編集</h2>

            <input
              type="text"
              data-todo-item-target="editInput"
              data-action="input->todo-item#validateEdit"
              class="w-full border rounded px-2 py-1"
            >

            <div class="flex justify-end gap-2">
              <button
                data-action="click->todo-item#cancelEdit"
                class="px-3 py-1 text-sm rounded border"
              >
                キャンセル
              </button>

              <button
                data-todo-item-target="saveEditButton"
                data-action="click->todo-item#saveEdit"
                class="px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-300"
              >
                保存
              </button>
            </div>
          </div>
        </div>
      </li>
    </template>
  </div>

</body>

</html>
todo_controller.js
import { Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js";

export default class extends Controller {
  static targets = [
    "input",
    "createButton",
    "list",
    "itemTemplate",
    "activeCount",
    "completedCount",
    "bulkActions",
  ];

  static outlets = ["todo-item"];

  // === Lifecycle / Outlet Callbacks ===
  // 子要素の増減に合わせて自動でカウントを更新
  todoItemOutletConnected() {
    this.updateCounts();
  }
  todoItemOutletDisconnected() {
    this.updateCounts();
  }

  // === Actions ===
  connect() {
    this.updateCounts();
  }

  // 入力内容に応じて追加ボタンの活性/非活性を切り替える
  validate() {
    const hasValue = this.inputTarget.value.trim().length > 0;
    this.createButtonTarget.disabled = !hasValue;
  }

  // Todoアイテムの作成
  create() {
    const value = this.inputTarget.value.trim();
    if (!value) return;

    const content = this.itemTemplateTarget.content.cloneNode(true);

    const itemElement = content.querySelector("[data-controller='todo-item']");

    itemElement.setAttribute("data-todo-item-title-value", value);

    this.listTarget.appendChild(content);
    this.inputTarget.value = "";

    this.validate();
  }

  // Todoアイテムを全て完了にする
  completeAll() {
    this.todoItemOutlets.forEach((item) => {
      item.completedValue = true;
    });
    this.updateCounts();
  }

  // Todoアイテムを全て未完了にする
  activateAll() {
    this.todoItemOutlets.forEach((item) => {
      item.completedValue = false;
    });
    this.updateCounts();
  }

  // 完了済みのTodoアイテムを全て削除する
  clearCompleted() {
    this.todoItemOutlets
      .filter((item) => item.completedValue)
      .forEach((item) => item.remove());
  }

  // 現在のTodo状態からカウントとUIを再計算する
  updateCounts() {
    const total = this.todoItemOutlets.length;
    const completed = this.todoItemOutlets.filter(item => item.completedValue).length;
    const active = total - completed;

    this.activeCountTarget.textContent = active;
    this.completedCountTarget.textContent = completed;

    // Todoが1件以上あれば一括ボタンを活性、なければ非活性
    this.bulkActionsTargets.forEach((button) => button.disabled = total === 0);
  }
}
todo_item_controller.js
import { Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js";

export default class extends Controller {
  static targets = [
    "content",
    "checkbox",
    "editModal",
    "editInput",
    "saveEditButton",
  ];

  static values = {
    title: String,
    completed: Boolean,
  };

  static classes = ["done"];

  // === Value change callbacks ===
  // タイトルが変わったら表示テキストを更新
  titleValueChanged() {
    this.contentTarget.textContent = this.titleValue;
  }

  // 完了状態が変わったらチェックボックスと見た目を更新
  completedValueChanged() {
    this.checkboxTarget.checked = this.completedValue;
    if (this.completedValue) {
      this.contentTarget.classList.add(...this.doneClasses);
    } else {
      this.contentTarget.classList.remove(...this.doneClasses);
    }
  }

  // === Actions ===
  // 完了状態を更新
  updateCompleted(event) {
    this.completedValue = event.target.checked;
    this.dispatch("completed-changed");
  }

  // 自身のTodo要素を削除
  remove() {
    this.element.remove();
  }

  // === Edit modal ===
  // 編集モーダルを開く
  openEditModal() {
    this.editInputTarget.value = this.titleValue;
    this.editModalTarget.classList.remove("hidden");

    this.editInputTarget.focus();
  }

  // 編集をキャンセルしてモーダルを閉じる
  cancelEdit() {
    this.editModalTarget.classList.add("hidden");
  }

  // 編集内容のバリデーション
  validateEdit() {
    const hasValue = this.editInputTarget.value.trim().length > 0;
    // 空文字なら保存ボタンを非活性にする
    this.saveEditButtonTarget.disabled = !hasValue;
  }

  // 編集内容を保存
  saveEdit() {
    const newTitle = this.editInputTarget.value.trim();
    if (!newTitle) return;

    this.titleValue = newTitle;
    this.editModalTarget.classList.add("hidden");
  }
}

Stimulusの処理の解説

HTML と Stimulus の基本

まずは入力フォームと追加ボタンの部分です。

<input
  type="text"
  placeholder="やることを入力"
  data-todo-target="input"
  data-action="input->todo#validate"
/>

<button
  data-todo-target="createButton"
  data-action="todo#create"
  disabled
>
  追加
</button>

targets / actions の意味

Targets

→ HTML の要素を JS 側から参照するためのラベルです。
data-todo-target="input" と書くと、todoコントローラー内では this.inputTarget としてアクセスできます。
Todoアプリでは、入力欄やボタン、Todoアイテムの各部分などをターゲットとして定義しています。

Actions

→ 「この要素でイベントが発生したら、指定したコントローラーのメソッドを呼ぶ」という仕組みです。
例えば data-action="input->todo#validate" は「この入力欄に文字が入力されたら、todo コントローラーの validate メソッドを呼ぶ」という意味です。
ボタンの data-action="todo#create" はクリックされたときに create メソッドを実行します。

Targets と Actions を組み合わせることで、HTML と JavaScript の結合を最小限にしつつ、要素を自由に操作できます。

対応するJavaScript側

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["input", "createButton", "list", "itemTemplate"];

  // 入力値に応じて追加ボタンの活性/非活性を切り替える
  validate() {
    const hasValue = this.inputTarget.value.trim().length > 0;
    this.createButtonTarget.disabled = !hasValue;
  }

  // Todoアイテムを追加
  create() {
    const value = this.inputTarget.value.trim();
    if (!value) return;

    const content = this.itemTemplateTarget.content.cloneNode(true);
    const itemElement = content.querySelector("[data-controller='todo-item']");
    itemElement.setAttribute("data-todo-item-title-value", value);

    this.listTarget.appendChild(content);
    this.inputTarget.value = "";

    // 入力が空になったのでボタンを非活性に戻す
    this.validate();
  }
}
  • this.inputTarget , this.createButtonTarget で要素を取得
  • Todoアイテム追加の処理は、HTML側で定義した<template> タグをもとに複製して追加しています

Stimulusのその他の機能

Values

static values = { title: String, completed: Boolean };
  • HTML 側で初期値を設定可能(例:data-todo-item-title-value="やること"
  • 値が変わると自動的に対応する titleValueChanged()completedValueChanged() が呼ばれます
titleValueChanged() {
  this.contentTarget.textContent = this.titleValue;
}

本処理ではUIを同期するために使用しています

Classes

static classes = ["done"];
  • HTML側 で data-todo-item-done-class="line-through text-gray-400 italic" と書くと this.doneClass で参照可能
if (this.completedValue) {
  this.contentTarget.classList.add(...this.doneClasses);
} else {
  this.contentTarget.classList.remove(...this.doneClasses);
}
  • this.doneClasses のように複数形で書くと配列で取得でき、複数のクラスをまとめて操作できます

Outlets

Stimulus では outlets を使うと、別のコントローラーにアクセスできます。

<div
  data-controller="todo"
  data-todo-todo-item-outlet="[data-controller='todo-item']"
>

親の todo コントローラー側では、static outlets = ["todo-item"] と書くことで、this.todoItemOutlets で子のコントローラーを配列として参照できます。

これにより、例えば全ての Todo アイテムを完了にしたり削除したり、まとめて操作することが簡単になります。

 static outlets = ["todo-item"];

  // Todoアイテムを全て完了にする
  completeAll() {
    this.todoItemOutlets.forEach((item) => {
      item.completedValue = true;
    });
    this.updateCounts();
  }

  // Todoアイテムを全て未完了にする
  activateAll() {
    this.todoItemOutlets.forEach((item) => {
      item.completedValue = false;
    });
    this.updateCounts();
  }

  // 完了済みのTodoアイテムを全て削除する
  clearCompleted() {
    this.todoItemOutlets
      .filter((item) => item.completedValue)
      .forEach((item) => item.remove());
  }

  // 現在のTodo状態からカウントとUIを再計算する
  updateCounts() {
    const total = this.todoItemOutlets.length;
    const completed = this.todoItemOutlets.filter(item => item.completedValue).length;
    const active = total - completed;

    this.activeCountTarget.textContent = active;
    this.completedCountTarget.textContent = completed;

    // Todoが1件以上あれば一括ボタンを活性、なければ非活性
    this.bulkActionsTargets.forEach((button) => button.disabled = total === 0);
  }
  • 子の追加・削除を検知して自動的に行われる処理を定義することもできます
todoItemOutletConnected() { this.updateCounts(); }
todoItemOutletDisconnected() { this.updateCounts(); }

Dispatch

this.dispatch("completed-changed");
  • コントローラー同士で通信するためにイベントを定義

  • 定義したイベントは data-action="todo-item:completed-changed->xxx" で受け取れる

Stimulus では dispatch を使うことで、親子間やコントローラー間で柔軟に状態の変化を伝えることができます。

例えば今回の Todo アプリでは:

<li data-controller="todo-item" data-action="todo-item:completed-changed->todo#updateCounts">
  <input type="checkbox" data-action="change->todo-item#updateCompleted">
</li>

チェックボックスが変わると updateCompleted が呼ばれ、
その中で this.dispatch("completed-changed") が実行され、
そのイベントを親の todo コントローラーが受け取り updateCounts を呼ぶ、
という流れになっています。

これにより「子の状態が変わったら親に通知してUIを更新する」という処理を、簡単に実装できます。

最後に

Stimulusを使ってTodoアプリを作ってみました。
本記事がお役に立てば幸いです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?