Welcome back to the "Ruby for AI" series. If Hotwire and Turbo are Rails' answer to "how far can we get with less JavaScript," then Stimulus is the answer to "okay fine, but I still need a little JavaScript."
That is why Stimulus is so good.
It does not try to replace your whole app architecture. It does not ask you to move all your logic client-side. It just gives you a clean way to attach behavior to HTML.
For Rails developers, especially the ones who do not want to become full-time frontend framework maintainers, that is a great deal.
In this post, we will cover what Stimulus is, the core concepts like controllers, targets, actions, and values, and how to use it for small AI-style interactions without turning your Rails app into chaos.
What Stimulus actually is
Stimulus is a lightweight JavaScript framework built for enhancing server-rendered HTML.
That last part matters.
Stimulus is not trying to own rendering. It assumes your HTML already exists, usually because Rails rendered it. Then it attaches behavior to that HTML.
You write a controller, connect it to an element, and define how it reacts to user interaction.
That makes it a perfect match for Hotwire.
- Rails renders HTML
- Turbo updates the page
- Stimulus adds focused behavior where needed
That stack is incredibly productive.
Why Rails developers tend to like Stimulus
Because it respects your time.
You do not need to invent a frontend application architecture just to:
- auto-resize a textarea
- copy generated output to clipboard
- preview a prompt count
- toggle advanced options
- debounce a search input
These are tiny interactions. They deserve tiny tools.
That is the real strength of Stimulus.
The basic mental model
Stimulus has four core ideas you need to know:
- controller: the behavior unit
- target: an element your controller references
- action: an event handler in HTML
- value: structured data passed from HTML into the controller
That sounds abstract, so let us make it concrete.
Your first Stimulus controller
Suppose you want a prompt textarea that shows live character count.
Create a controller:
// app/javascript/controllers/prompt_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "count"]
updateCount() {
this.countTarget.textContent = this.inputTarget.value.length
}
}
Now wire it to HTML:
<div data-controller="prompt">
<textarea
data-prompt-target="input"
data-action="input->prompt#updateCount"
></textarea>
<p>Characters: <span data-prompt-target="count">0</span></p>
</div>
What happens here?
-
data-controller="prompt"attaches the controller -
data-prompt-target="input"marks the textarea as a target -
data-action="input->prompt#updateCount"says: on input event, runupdateCount -
data-prompt-target="count"gives us the counter element
That is the whole pattern.
No giant component tree. No global state drama. Just behavior attached where it belongs.
Controllers: one job, small scope
A Stimulus controller should usually do one focused thing.
Good examples:
- character counter
- copy button
- loading state toggle
- dropdown opener
- keyboard shortcut handler
Bad examples:
- the entire frontend logic for your whole app stuffed into one controller
Stimulus works best when controllers stay small and local.
Targets: your named handles into the DOM
Targets are how your controller finds the elements it cares about.
Example:
static targets = ["prompt", "output"]
fillExample() {
this.promptTarget.value = "Explain Ruby blocks like I'm a junior developer"
this.outputTarget.textContent = "Example prompt inserted"
}
And in HTML:
<div data-controller="playground">
<textarea data-playground-target="prompt"></textarea>
<div data-playground-target="output"></div>
<button data-action="click->playground#fillExample">Use Example</button>
</div>
Targets make the DOM interactions readable. You are not hunting through random selectors all over the place.
Actions: event wiring without glue code mess
Actions map browser events to controller methods.
Example:
<button data-action="click->prompt#submit">Run Prompt</button>
<input data-action="keyup->search#filter" />
This is one of the nicest parts of Stimulus. The event wiring stays right next to the HTML that uses it.
That makes Rails views easier to read because the behavior is visible where it matters.
Values: pass data in cleanly
Values are how you give configuration to a controller.
Controller:
// app/javascript/controllers/copier_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { successMessage: String }
static targets = ["source", "notice"]
async copy() {
await navigator.clipboard.writeText(this.sourceTarget.textContent)
this.noticeTarget.textContent = this.successMessageValue
}
}
HTML:
<div
data-controller="copier"
data-copier-success-message-value="Copied to clipboard"
>
<pre data-copier-target="source"><%= @prompt_run.output %></pre>
<p data-copier-target="notice"></p>
<button data-action="click->copier#copy">Copy</button>
</div>
That is much cleaner than sprinkling magic constants through the JavaScript.
A practical AI-flavored example
Let us build a small enhancement for a prompt form:
- show character count
- disable submit if prompt is empty
- toggle advanced options
Controller:
// app/javascript/controllers/prompt_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "count", "submit", "advanced"]
connect() {
this.refresh()
}
refresh() {
const length = this.inputTarget.value.trim().length
this.countTarget.textContent = length
this.submitTarget.disabled = length === 0
}
toggleAdvanced() {
this.advancedTarget.classList.toggle("hidden")
}
}
HTML:
<div data-controller="prompt-form">
<textarea
data-prompt-form-target="input"
data-action="input->prompt-form#refresh"
></textarea>
<p>Characters: <span data-prompt-form-target="count">0</span></p>
<button
type="button"
data-action="click->prompt-form#toggleAdvanced"
>
Advanced options
</button>
<div class="hidden" data-prompt-form-target="advanced">
<label>Temperature</label>
<input type="number" step="0.1" value="0.7" />
</div>
<button data-prompt-form-target="submit">Run Prompt</button>
</div>
That is the kind of UX improvement Stimulus is perfect for. Small, useful, and easy to maintain.
Where Stimulus shines in AI apps
Stimulus is great for all the "just enough UI" interactions AI apps tend to need:
- copy output buttons
- prompt length counters
- tab switching between results and metadata
- loading state indicators
- inline parameter toggles
- expandable reasoning or debug panels
- keyboard shortcuts for prompt workflows
These are meaningful UX upgrades, but they do not justify a huge frontend stack on their own.
Common mistakes with Stimulus
1. Writing giant controllers
If one controller is doing ten unrelated things, split it up.
2. Recreating a frontend framework badly
Stimulus is not trying to be React. Do not force it into becoming one.
3. Ignoring server-rendered HTML strengths
If Rails already knows how to render the UI, let Rails render it.
4. Using Stimulus for everything
Use it for behavior, not for your entire mental model of the app.
Stimulus + Turbo is the sweet spot
This is the combo a lot of Rails developers end up loving:
- Turbo handles navigation and server-driven updates
- Stimulus adds focused interactivity
That means you can build apps that feel dynamic without giving up Rails simplicity.
For AI builders, that is a big deal. AI products usually change fast. Prompts change. flows change. UI requirements change. The simpler your stack, the faster you can adapt.
Final takeaway
Stimulus is the right kind of JavaScript for many Rails apps.
It gives you enough power to add polish, responsiveness, and interaction without dragging you into frontend architecture overkill.
That makes it a perfect fit for Rails developers who want to ship AI-powered products without turning every feature into a JS framework debate.
Use Stimulus when you need behavior. Use Turbo when you need server-driven updates. Use Rails to keep the rest boring.
That is a very good stack.
Top comments (0)