Stimulus とは
Stimulus は 軽量な JavaScript フレームワークです。
「HTML に少しだけ付加情報を足す」だけで動き、Rails のようなサーバーサイドレンダリングを行うアプリケーションでも、既存のHTMLの延長として JS の機能を追加できます。
Rails界隈では知られている方も多いですが、フロント単体で触る機会はまだ少ないかもしれません。
この記事では、簡単な Todo アプリを作りながら、Stimulus の基本と便利な機能を紹介します。
今回作るアプリ
今回は Todoアプリ を作りながら、Stimulusの特徴を学んでいきます。
- タスクの追加・完了・削除
- タスクの一括操作(全て完了 / 全て未完了 / 完了済み削除)
- 編集モーダル
- 未完了・完了タスクのカウント
- 入力バリデーションでボタンの活性/非活性切替
このアプリ1つで targets / actions / values / classes / outlets / dispatch といったStimulusの主要機能が全部登場します。
アプリイメージ
実際に動かす
S3にデプロイ済みです。以下のリンクから確認できます:
Todoアプリのリンク
コードを確認したい方は GitHub にもあります:
https://github.com/chain792/stimulus-todo-app
全体のソースコード
<!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>
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);
}
}
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アプリを作ってみました。
本記事がお役に立てば幸いです。
