Stimulus Integration
Preview ecosystem guide: Asset Pipeline is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.
The Asset Pipeline provides first-class support for Stimulus, the modest JavaScript framework from Hotwire. It automatically detects controllers, handles imports, and registers them with the Stimulus application.
Basic Setup
Configure Stimulus
front_loader = AssetPipeline::FrontLoader.new(
js_source_path: Path["src/javascript"],
js_output_path: Path["public/javascript"]
) do |import_maps|
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])
# Add Stimulus framework
import_map.add_import(
"@hotwired/stimulus",
"https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm",
preload: true
)
# Add controllers
import_map.add_import("HelloController", "hello_controller.js")
import_map.add_import("DropdownController", "dropdown_controller.js")
import_maps << import_map
end
Render in Layout
<!doctype html>
<html>
<head>
<title>My App</title>
<%= FRONT_LOADER.render_import_map_tag %>
</head>
<body>
<%= content %>
<%= FRONT_LOADER.render_stimulus_initialization_script %>
</body>
</html>
Automatic Controller Detection
Controllers ending with "Controller" are automatically detected and registered:
# These are detected as Stimulus controllers
import_map.add_import("HelloController", "hello_controller.js")
import_map.add_import("DropdownController", "dropdown_controller.js")
import_map.add_import("UserProfileController", "user_profile_controller.js")
# This is NOT detected (no "Controller" suffix)
import_map.add_import("utils", "utils.js")
Name Conversion
PascalCase controller names are converted to kebab-case for registration:
| Import Name | Registered As | HTML Data Attribute |
|-------------|---------------|---------------------|
| HelloController | hello | data-controller="hello" |
| DropdownController | dropdown | data-controller="dropdown" |
| UserProfileController | user-profile | data-controller="user-profile" |
Writing Controllers
Basic Controller
// src/javascript/hello_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["name", "output"]
greet() {
const name = this.nameTarget.value || "World"
this.outputTarget.textContent = `Hello, ${name}!`
}
}
Using in HTML
<div data-controller="hello">
<input data-hello-target="name" type="text" placeholder="Your name">
<button data-action="click->hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>
Controller with Values
// src/javascript/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
seconds: { type: Number, default: 60 }
}
static targets = ["display"]
connect() {
this.start()
}
start() {
this.remaining = this.secondsValue
this.timer = setInterval(() => this.tick(), 1000)
}
tick() {
this.remaining--
this.displayTarget.textContent = this.remaining
if (this.remaining <= 0) {
clearInterval(this.timer)
this.dispatch("finished")
}
}
disconnect() {
clearInterval(this.timer)
}
}
<div data-controller="countdown" data-countdown-seconds-value="30">
Time remaining: <span data-countdown-target="display">30</span>
</div>
Custom Initialization
Add custom JavaScript alongside Stimulus initialization:
custom_js = <<-JS
// Global utilities
window.formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
// App initialization
document.addEventListener('DOMContentLoaded', () => {
console.log('Application ready');
});
// Custom event handlers
document.addEventListener('stimulus:ready', () => {
console.log('All controllers registered');
});
JS
front_loader.render_stimulus_initialization_script(custom_js)
Multiple Applications
Create separate Stimulus applications for different areas:
front_loader = AssetPipeline::FrontLoader.new do |import_maps|
# Main application
main_map = AssetPipeline::ImportMap.new("main")
main_map.add_import("@hotwired/stimulus", "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm", preload: true)
main_map.add_import("UserController", "user_controller.js")
import_maps << main_map
# Admin application
admin_map = AssetPipeline::ImportMap.new("admin")
admin_map.add_import("@hotwired/stimulus", "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm", preload: true)
admin_map.add_import("AdminController", "admin_controller.js")
admin_map.add_import("ChartController", "chart_controller.js")
import_maps << admin_map
end
# Render for different pages
main_html = front_loader.render_stimulus_initialization_script("", "main", "mainApp")
admin_html = front_loader.render_stimulus_initialization_script("", "admin", "adminApp")
Combining with Libraries
Chart.js Integration
import_map.add_import("@hotwired/stimulus", "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm", preload: true)
import_map.add_import("chart.js", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
import_map.add_import("ChartController", "chart_controller.js")
// src/javascript/chart_controller.js
import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"
export default class extends Controller {
static values = {
type: { type: String, default: "line" },
data: Object
}
connect() {
this.chart = new Chart(this.element, {
type: this.typeValue,
data: this.dataValue
})
}
disconnect() {
this.chart.destroy()
}
}
Debouncing with Lodash
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
import_map.add_import("SearchController", "search_controller.js")
// src/javascript/search_controller.js
import { Controller } from "@hotwired/stimulus"
import { debounce } from "lodash"
export default class extends Controller {
static targets = ["input", "results"]
initialize() {
this.search = debounce(this.search, 300).bind(this)
}
search() {
const query = this.inputTarget.value
fetch(`/search?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => this.displayResults(data))
}
displayResults(data) {
this.resultsTarget.innerHTML = data.map(item =>
`<li>${item.name}</li>`
).join('')
}
}
Generated Output
The Asset Pipeline generates clean, optimized output:
<script type="importmap">
{
"imports": {
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm",
"HelloController": "/javascript/hello_controller.js",
"DropdownController": "/javascript/dropdown_controller.js"
}
}
</script>
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm">
<script type="module">
import { Application } from "@hotwired/stimulus";
import HelloController from "HelloController";
import DropdownController from "DropdownController";
const application = Application.start();
// Custom initialization code here
application.register("hello", HelloController);
application.register("dropdown", DropdownController);
</script>
Duplicate Removal
If you have existing Stimulus code, the Asset Pipeline automatically removes duplicates:
# Your existing code with manual imports
existing_js = <<-JS
import { Application } from "@hotwired/stimulus";
import HelloController from "HelloController";
const application = Application.start();
application.register("hello", HelloController);
// This custom code is kept
console.log('App ready');
JS
# Asset Pipeline removes duplicate imports/registrations
result = front_loader.render_stimulus_initialization_script(existing_js)
Best Practices
1. One Controller Per Feature
// Good: Single responsibility
// dropdown_controller.js - handles dropdowns
// modal_controller.js - handles modals
// Avoid: God controller
// application_controller.js - handles everything
2. Use Targets Over querySelector
// Good: Stimulus targets
static targets = ["input", "output"]
this.inputTarget.value
this.outputTarget.textContent = "Hello"
// Avoid: Manual DOM queries
document.querySelector('.input').value
3. Use Values for Configuration
// Good: Configurable via HTML
static values = {
url: String,
delay: { type: Number, default: 300 }
}
// HTML: data-fetch-url-value="/api/data"
// Avoid: Hardcoded values
const url = "/api/data"
4. Dispatch Events for Communication
// Controller A
this.dispatch("selected", { detail: { item: this.item } })
// Controller B
static targets = ["container"]
itemSelected(event) {
console.log(event.detail.item)
}
// HTML
// data-action="controller-a:selected->controller-b#itemSelected"