Stimulus Controllers Explained — Step-by-Step Beginner's Guide
Stimulus controllers are JavaScript classes that add interactive behavior to HTML through targets and actions — no complex build tools or framework setup required.
What You’ll Learn
By the end of this tutorial, you’ll understand how to create Stimulus controllers, wire up targets and actions, use values and CSS classes, and build interactive components without leaving your server-rendered HTML.
Why Stimulus Controllers Matter
Stimulus was created by the team at Basecamp (the makers of Ruby on Rails) to solve a specific problem: how do you add JavaScript behavior to HTML that’s already been rendered by the server? Unlike React or Vue, Stimulus doesn’t take over the entire page. It sits on top of your existing HTML and adds behavior — like a remote control for your DOM elements.
Think of it this way: your server sends HTML to the browser. That HTML is like a printed document. Stimulus is the highlighter pen that lets you mark things up and make them interactive. This is why Stimulus is the backbone of the Doda Browser extension UI — it enhances static HTML panels with interactive behavior without rebuilding the entire interface in a JavaScript framework.
flowchart LR
A["HTML (server-rendered)"] --> B["Stimulus Controller"]
B --> C["Targets (DOM handles)"]
B --> D["Actions (event bindings)"]
B --> E["Values (config from HTML)"]
C --> F["Interactive UI"]
D --> F
E --> F
style B fill:#4f46e5,color:#fff
style F fill:#059669,color:#fff
Stimulus vs React vs Alpine.js — When to Use What
| Feature | Stimulus | React | Alpine.js |
|---|---|---|---|
| Renders HTML | No (enhances existing) | Yes (virtual DOM) | No (enhances existing) |
| Build step | Optional | Required | Optional |
| Learning curve | Low | High | Low |
| Best for | Server-rendered apps | SPAs, complex UIs | Lightweight reactivity |
| File size | ~10KB | ~140KB+ | ~7KB |
When to choose Stimulus: You have a server-rendered app (Rails, Laravel, Django) and need to sprinkle in interactivity — tabs, modals, forms, autocomplete. If you need a full reactive SPA, reach for React. If you want declarative bindings in HTML without a controller class, Alpine.js is your friend.
How Stimulus Works: The Remote Control Analogy
Imagine your TV remote control. The remote doesn’t replace the TV — it just gives you buttons to control it. In Stimulus:
- The controller is your remote control (a JavaScript class)
- Targets are the buttons on your remote (named DOM elements you can reference)
- Actions are what happens when you press a button (event → method bindings)
- Values are the settings you configure (like volume level stored in the TV)
Step 1: Setting Up Stimulus
You can use Stimulus directly from a CDN — no build step needed:
<script defer src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.x.x/dist/stimulus.umd.min.js"></script>When this script loads, it creates a global Stimulus object. You then start the application:
const application = Stimulus.Application.start()Think of Application.start() as plugging in your remote control receiver. Now it’s ready to pair with controllers.
Step 2: Your First Controller
A controller is a class that extends Stimulus.Controller. You register it with a name (identifier) so Stimulus knows how to connect it to HTML:
application.register('hello', class extends Stimulus.Controller {
connect() {
console.log('Hello controller connected!')
}
})The connect() method is called automatically when the controller’s HTML element appears in the DOM. This is your setup hook.
Step 3: Wiring HTML to the Controller
To attach a controller to HTML, use the data-controller attribute:
<div data-controller="hello">
<p>This element is now controlled by Stimulus.</p>
</div>That’s it. When this div enters the DOM, Stimulus finds data-controller="hello", looks up the registered hello controller, creates an instance, and calls connect().
Targets: Named DOM Handles
Targets let you give names to important elements inside your controller’s scope. Instead of writing document.querySelector(...), you declare a target and Stimulus generates a property for it.
Declaring Targets
application.register('hello', class extends Stimulus.Controller {
static targets = ['name', 'output']
connect() {
console.log(this.nameTarget) // The <input> element
console.log(this.outputTarget) // The <p> element
console.log(this.hasNameTarget) // true/false
}
})HTML Targets
<div data-controller="hello">
<input type="text" data-hello-target="name">
<p data-hello-target="output"></p>
</div>Naming rule: HTML uses kebab-case (data-hello-target="error-message"), JavaScript uses camelCase (this.errorMessageTarget).
Why targets instead of querySelector? Targets are self-documenting — you can see at a glance which DOM elements a controller depends on. They’re also scoped to the controller’s element, so you never accidentally grab an element from outside.
Your controller gets three properties per target:
| Property | Returns |
|---|---|
this.nameTarget | First matching element |
this.nameTargets | Array of all matching elements |
this.hasNameTarget | Boolean (does it exist?) |
Actions: Connecting Events to Methods
Actions are the bridge between DOM events and controller methods. The syntax is simple:
<button data-action="click->hello#greet">Greet</button>Let’s break this down:
click— the DOM event namehello— the controller identifiergreet— the method to call
The full syntax is event->controller#method. You can omit the event name if it’s click:
<button data-action="hello#greet">Greet</button>
<!-- Same as click->hello#greet -->Complete Example: A Greeter
Let’s put controllers, targets, and actions together:
<div data-controller="hello">
<input type="text" data-hello-target="name" placeholder="Your name">
<button data-action="click->hello#greet">Greet</button>
<p data-hello-target="output"></p>
</div>
<script>
const app = Stimulus.Application.start()
app.register('hello', class extends Stimulus.Controller {
static targets = ['name', 'output']
greet() {
const name = this.nameTarget.value || 'World'
this.outputTarget.textContent = `Hello, ${name}!`
}
})
</script>What happens step by step:
- User types a name and clicks “Greet”
- The
clickevent triggershello#greet greet()readsthis.nameTarget.value(the input)- It sets
this.outputTarget.textContent(the paragraph)
Expected output: The paragraph displays “Hello, [name]!”
Global Events
You can listen for events on window or document using the @ suffix:
<div data-action="scroll@window->scroll#handle">...</div>
<div data-action="keydown@document->keys#handle">...</div>Values: Configuration from HTML
Values let you pass data from HTML attributes into your controller. Think of them as controller settings that can be changed without touching JavaScript.
app.register('timer', class extends Stimulus.Controller {
static values = { interval: { type: Number, default: 1000 } }
static targets = ['display']
start() {
this._interval = setInterval(() => {
this.displayTarget.textContent =
parseInt(this.displayTarget.textContent) + 1
}, this.intervalValue)
}
})<div data-controller="timer" data-timer-interval-value="500">
<span data-timer-target="display">0</span>
<button data-action="click->timer#start">Start</button>
</div>Why values? They make your controllers reusable. The same timer controller can run at different speeds based on the HTML attribute. In the Doda Browser extension, values are used to configure panel widths, refresh intervals, and API endpoints without modifying JavaScript.
Stimulus supports these value types:
| Type | HTML Example | JS Access |
|---|---|---|
| String | data-timer-name-value="hello" | this.nameValue |
| Number | data-timer-count-value="5" | this.countValue |
| Boolean | data-timer-active-value="true" | this.activeValue |
| Array | data-timer-items-value='["a","b"]' | this.itemsValue |
| Object | data-timer-config-value='{"key":"val"}' | this.configValue |
Value Change Callbacks
When a value changes, Stimulus calls a {name}ValueChanged callback automatically:
static values = { count: Number }
countValueChanged(current, previous) {
console.log(`Count changed from ${previous} to ${current}`)
this.element.textContent = this.countValue
}Classes: Dynamic CSS
The CSS Classes API lets you toggle CSS classes defined in HTML:
<div data-controller="toggle"
data-toggle-active-class="bg-blue-500"
data-toggle-inactive-class="bg-gray-200">
<button data-action="click->toggle#toggle">Toggle</button>
</div>app.register('toggle', class extends Stimulus.Controller {
static classes = ['active', 'inactive']
toggle() {
this.element.classList.toggle(this.activeClass)
this.element.classList.toggle(this.inactiveClass)
}
})Multiple Controllers on One Element
A single element can have multiple controllers — useful for composing behaviors:
<div data-controller="tooltip dropdown">
<!-- Both tooltip and dropdown controllers run on this element -->
</div>Each controller is independent. They don’t interfere with each other.
Common Mistakes
1. Forgetting to register the controller
// ❌ Class exists but isn't registered
class Hello extends Stimulus.Controller { }
// ✅ Register it with the application
application.register('hello', class extends Stimulus.Controller { })2. Accessing targets before they exist (in initialize)
// ❌ initialize() runs before DOM is ready
initialize() {
this.nameTarget.value = 'Default' // Error!
}
// ✅ Use connect() for DOM-dependent code
initialize() {
this.defaultName = 'Default'
}
connect() {
this.nameTarget.value = this.defaultName
}3. Using dashes in HTML when JavaScript expects camelCase
<!-- HTML uses kebab-case -->
<div data-action="click->my-controller#do-something">// JavaScript uses camelCase
doSomething() { }4. Not checking if a target exists
// ❌ Crashes if the target element is missing
this.nameTarget.value = ''
// ✅ Guard with has check
if (this.hasNameTarget) {
this.nameTarget.value = ''
}5. Forgetting to clean up in disconnect
// ❌ Timer keeps running after controller is removed
connect() {
this._timer = setInterval(() => {}, 1000)
}
// ✅ Clean up in disconnect
disconnect() {
clearInterval(this._timer)
}6. Confusing data attributes naming
// ❌ Wrong attribute name pattern
data-hello-target="name" // Correct: data-{controller}-target="{name}"
// ❌ Wrong value attribute
data-timer-interval="1000" // Wrong: missing "-value" suffix
data-timer-interval-value="1000" // ✅ Correct
Practice Questions
1. What does connect() do in a Stimulus controller?
It’s called automatically when the controller’s DOM element is attached to the page. Use it for setup that needs the DOM to exist.
2. How do you reference an input element inside a controller?
Declare it as a target (static targets = ['input']) and access it via this.inputTarget in JavaScript. Mark the HTML with data-controller-target="input".
3. What’s the difference between this.nameTarget and this.nameTargets?
this.nameTarget returns the first matching element. this.nameTargets returns an array of all matching elements.
4. How do you pass configuration from HTML to a controller?
Use values: declare static values = { delay: Number } in the controller, then set data-controller-delay-value="300" in HTML.
🏆 Challenge
Build a color picker controller. It should have a target for the color input, a target for a preview div, and update the preview background color on input change. Use data-action="input->picker#update".
FAQ
Try It Yourself
Copy this entire HTML file and open it in your browser. It’s a fully working Stimulus demo.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stimulus Controller Demo</title>
<script defer src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.x.x/dist/stimulus.umd.min.js"></script>
</head>
<body style="font-family: system-ui, sans-serif; max-width: 500px; margin: 2rem auto; padding: 0 1rem;">
<h1>Stimulus Controller Demo</h1>
<div data-controller="counter" data-counter-step-value="1">
<h2>Counter</h2>
<p style="font-size: 2rem;" data-counter-target="display">0</p>
<button data-action="click->counter#decrement">-</button>
<button data-action="click->counter#reset">Reset</button>
<button data-action="click->counter#increment">+</button>
</div>
<hr>
<div data-controller="greeter" data-greeter-greeting-value="Hey">
<h2>Greeter</h2>
<input type="text" data-greeter-target="name" placeholder="Your name">
<button data-action="click->greeter#greet">Greet</button>
<p data-greeter-target="output"></p>
</div>
<script>
(() => {
const app = Stimulus.Application.start()
app.register('counter', class extends Stimulus.Controller {
static targets = ['display']
static values = { step: { type: Number, default: 1 } }
connect() { this.count = 0 }
increment() {
this.count += this.stepValue
this.displayTarget.textContent = this.count
}
decrement() {
this.count -= this.stepValue
this.displayTarget.textContent = this.count
}
reset() {
this.count = 0
this.displayTarget.textContent = this.count
}
})
app.register('greeter', class extends Stimulus.Controller {
static targets = ['name', 'output']
static values = { greeting: String }
greet() {
const name = this.nameTarget.value.trim() || 'World'
this.outputTarget.textContent = `${this.greetingValue}, ${name}!`
}
})
})()
</script>
</body>
</html>Try this: Click the counter buttons, then change data-counter-step-value="5" and click again. Notice how the step changes without any JavaScript changes.
What’s Next
| Topic | Description |
|---|---|
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-lifecycle-state/ | Controller lifecycle callbacks and state management |
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-communication/ | Cross-controller communication with events and outlets |
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-patterns/ | Real-world patterns: forms, modals, infinite scroll |
| Alpine.js | Compare Stimulus with Alpine.js for reactive UIs |
| HTMX | Using Stimulus alongside HTMX for AJAX interactions |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This pattern is used in the Doda Browser extension to power interactive settings panels, tab management, and bookmark organizers using server-rendered HTML enhanced with Stimulus controllers.
What’s Next
Congratulations on completing this Stimulus Getting Started tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro