Skip to content
Stimulus Controllers Explained — Step-by-Step Beginner's Guide

Stimulus Controllers Explained — Step-by-Step Beginner's Guide

DodaTech Updated Jun 6, 2026 10 min read

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
  
Prerequisites: You should know basic HTML and JavaScript (ES6 class syntax). No build tools required — we’ll use Stimulus via CDN. If you’ve used jQuery before, you’ll feel right at home.

Stimulus vs React vs Alpine.js — When to Use What

FeatureStimulusReactAlpine.js
Renders HTMLNo (enhances existing)Yes (virtual DOM)No (enhances existing)
Build stepOptionalRequiredOptional
Learning curveLowHighLow
Best forServer-rendered appsSPAs, complex UIsLightweight 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:

PropertyReturns
this.nameTargetFirst matching element
this.nameTargetsArray of all matching elements
this.hasNameTargetBoolean (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 name
  • hello — the controller identifier
  • greet — 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:

  1. User types a name and clicks “Greet”
  2. The click event triggers hello#greet
  3. greet() reads this.nameTarget.value (the input)
  4. 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:

TypeHTML ExampleJS Access
Stringdata-timer-name-value="hello"this.nameValue
Numberdata-timer-count-value="5"this.countValue
Booleandata-timer-active-value="true"this.activeValue
Arraydata-timer-items-value='["a","b"]'this.itemsValue
Objectdata-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

Do I need a build tool to use Stimulus?

No! You can use Stimulus via CDN with zero build tools. Just include the script tag and start writing controllers. For production apps with Webpack or esbuild, the npm package @hotwired/stimulus integrates cleanly.

Can I use Stimulus with React or Vue?

You can, but it’s unusual. Stimulus shines with server-rendered HTML. If you’re already using React, you probably don’t need Stimulus. However, Stimulus can wrap React components for hybrid setups.

How do I debug Stimulus controllers?

Use console.log(this) inside any controller method to inspect the controller instance. The Stimulus DevTools browser extension (Chrome/Firefox) shows all registered controllers, their targets, and values.

Does Stimulus work with Turbo?

Yes. HTMX and Turbo are complementary. Stimulus handles client-side behavior; Turbo handles page navigation. They’re part of the Hotwire suite.

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

TopicDescription
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.jsCompare Stimulus with Alpine.js for reactive UIs
HTMXUsing 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