Stimulus controllers in practice
Stimulus has a small surface: targets, values, classes, outlets, actions, and three lifecycle callbacks. You can read the whole reference in twenty minutes. What the reference will not tell you is which parts you actually reach for, which part becomes load-bearing the moment Turbo Drive is in the page, and what it costs to test any of it.
So this is the API read out of a codebase instead of out of the docs. The LaunchKit boilerplate ships
25 files matching app/javascript/controllers/*_controller.js, against stimulus-rails 1.3.4, which
vendors Stimulus 3.2.2, and turbo-rails 2.0.23. Every quote below is from one of those files.
What rails generate stimulus actually writes
The generator is 22 lines. StimulusGenerator in stimulus-rails 1.3.4 renders one template into
app/javascript/controllers/#{controller_name}_controller.js, and the template is this, entire:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="<%= @attribute %>"
export default class extends Controller {
connect() {
}
}
Two details in the generator matter more than the file it produces. The first is the name mangling:
def stimulus_attribute_value(controller_name)
controller_name.gsub(/\//, "--").gsub("_", "-")
end
A slash becomes a double hyphen and an underscore becomes a single one. rails generate stimulus
admin/dirty_form produces admin/dirty_form_controller.js and the comment tells you to reach it as
data-controller="admin--dirty-form". The boilerplate keeps its file flat at
admin_dirty_form_controller.js, so the admin layout reaches it as data-controller="admin-dirty-form"
with one hyphen. Get that wrong and nothing errors. The controller simply never connects.
The second detail is the registration branch:
rails_command "stimulus:manifest:update" unless Rails.root.join("config/importmap.rb").exist? || options[:skip_manifest]
On a bundled app the rake task overwrites app/javascript/controllers/index.js with an explicit
application.register line per controller. On an importmap app the generator deliberately does
nothing, because registration is already wholesale: pin_all_from "app/javascript/controllers", under:
"controllers" in config/importmap.rb, and eagerLoadControllersFrom("controllers", application) in
index.js. Dropping a _controller.js file into the directory is the whole installation step.
Targets, and the has prefix that makes them optional
Targets are the reason a Stimulus controller can be moved between two pages that do not look alike.
static targets = ["source", "button"] in clipboard_controller.js generates sourceTarget,
sourceTargets and hasSourceTarget, and the singular getter throws Missing target element when
nothing matches.
That last sentence is the whole design pressure. A controller that reads this.buttonTarget
unguarded is a controller that requires that element on every page it appears on. The clipboard
controller does not:
flash() {
if (!this.hasButtonTarget) return
const original = this.buttonTarget.textContent
this.buttonTarget.textContent = this.copiedValue
setTimeout(() => { this.buttonTarget.textContent = original }, 1500)
}
Which is why the same markup works on referrals/show.html.erb, where the source is an
<input> next to a button, and in admin/settings/show.html.erb, where it is a three-row
<textarea>.
The plural getter is the other half. password_strength_controller.js declares four targets and
treats one of them as a collection:
this.ruleTargets.forEach((rule) => {
const key = rule.dataset.rule
const passed = key === "match"
? password.length > 0 && password === confirmation
: Boolean(this.constructor.PREDICATES[key]?.(password))
rule.classList.toggle("is-valid", passed)
if (!passed) allValid = false
})
Note what is not a target here: the rule's identity. data-rule="uppercase" is a plain data
attribute read through rule.dataset.rule, because six rules would otherwise mean six target names
and six branches. Targets answer "which element", not "which one of these".
Values, the only typed door from ERB
Values are how a number computed in Ruby gets into JavaScript without a <script> tag. Twelve of the
25 controllers declare them, and the declaration carries the type:
static values = {
kind: { type: String, default: "line" },
data: Object,
options: { type: Object, default: {} }
}
chart_controller.js reads this.dataValue as a real object because Stimulus parsed
data-chart-data-value="<%= {labels: [...], datasets: [...]}.to_json %>" for it. Declaring Object
is what turns the attribute from a string into JSON.parse output, and the same mechanism gives
Number, Boolean and Array.
Defaults are worth more than they look. static values = { copied: { type: String, default: "Copied!" } }
means the clipboard controller works with no value attribute at all, while
data-clipboard-copied-value="<%= t("referrals.show.copied") %>" localises it. A default is how you
keep a value optional without writing a hasCopiedValue guard.
The large end of this is admin_plan_controller.js, which declares nine values including
fetchPriceUrl, createProductsUrl and catalogUrl. Five URLs passed in as values rather than five
hardcoded paths, because the controller lives in app/javascript where Rails.application.routes does not
exist. Passing routes in as values is the standard answer to that, and it is the reason a Stimulus
controller almost never needs to know your route names.
Stimulus also generates a [name]ValueChanged callback for every declared value, fired once on
connect and again whenever the attribute is rewritten. None of the 25 controllers uses one, which is
a fair signal: the callback pays off when something else on the page mutates your attributes, and in
practice most values are written once by ERB and read once on connect.
Actions bound above the fields they watch
Actions read as data-action="event->identifier#method", and the interesting choice is not the
syntax but where you put the attribute. The obvious placement is on the element itself, one per
input:
data: { password_strength_target: "password", action: "input->password-strength#validate" }
The other placement is on an ancestor, once, for everything below it. app/views/layouts/admin.html.erb
does that for the entire admin:
<div class="lg:pl-64" data-controller="admin-dirty-form"
data-action="input->admin-dirty-form#check change->admin-dirty-form#check">
One binding, every form in the admin, no per-field markup. input and change bubble, so the
listener on the wrapper sees fields that did not exist when it connected. The controller never learns
which form it is guarding; it snapshots each enclosing <form> on connect and diffs them:
check() {
const dirty = this.forms.find((form) => this.snapshots.has(form) && this.serialize(form) !== this.snapshots.get(form))
this.dirtyForm = dirty || null
if (dirty) this.show()
else this.hide()
}
The comment above that method names the reason it re-checks every form instead of event.target.form:
"a picker that mutates a hidden field in JS and dispatches a bubbling input event still gets caught,
even when the event target resolves oddly". That is the cost of delegating: you gain markup you never
have to maintain, and you lose the guarantee that event.target is the thing that changed.
Classes and outlets, absent from all twenty-five
Neither static classes nor static outlets appears anywhere in the boilerplate's controllers. The
grep returns nothing.
Classes solve a narrow problem: a controller that toggles a CSS class shipped by a design system it
should not hardcode. In a Tailwind codebase the class names are already in the ERB, and controllers
here just write classList.toggle("is-valid", passed) or classList.add("hidden") and accept the
coupling.
Outlets are a bigger claim and a worse habit. An outlet lets one controller hold a reference to
another controller's instance by CSS selector, and when the selector matches nothing you get
Stimulus couldn't find a matching outlet element using selector at runtime. Most outlet
relationships are one controller wanting to tell another that something happened, which is what
this.dispatch() and a data-action already do, without either side knowing the other's selector.
Take the outlet when you genuinely need to read state off a sibling controller; reach for an event
first.
The disconnect Turbo's cache makes load-bearing
Stimulus documents disconnect() plainly: it fires "anytime the controller is disconnected from the
DOM", including when a parent element leaves the document. Turbo Drive replaces document.body on
every navigation, so disconnect does fire, and a listener attached to this.element genuinely does
go away with it. The leak story most posts tell is not the real one.
The real one is ordering. Turbo caches the page before it renders the new one. In turbo-rails
2.0.23, the render callback inside Visit#loadResponse opens with
if (this.shouldCacheSnapshot) this.cacheSnapshot() and only then awaits renderPageSnapshot.
View#cacheSnapshot is this:
async cacheSnapshot(snapshot = this.snapshot) {
if (snapshot.isCacheable) {
this.delegate.viewWillCacheSnapshot()
const {lastRenderedLocation: location} = this
await nextEventLoopTick()
const cachedSnapshot = snapshot.clone()
this.snapshotCache.put(location, cachedSnapshot)
return cachedSnapshot
}
}
viewWillCacheSnapshot() is what dispatches turbo:before-cache. Then the clone is taken. Only
after that does the body get replaced, which is when Stimulus finally calls your disconnect(). The
snapshot already went in the cache with whatever the DOM looked like at that moment.
markdown_editor_controller.js is the case where that bites. Its buildToolbar() inserts a div
outside the controller element:
this.textarea.insertAdjacentElement("beforebegin", this.toolbar)
and disconnect() cleans up after itself:
disconnect() {
this.textarea?.removeEventListener("input", this.autoGrowHandler)
this.toolbar?.remove()
}
That cleanup is correct and still not sufficient, because the snapshot was cloned before it ran. Press back into that page and the restored snapshot contains a toolbar, and
connect() builds a second one on top of it. Anything a controller injects outside its own element
needs a turbo:before-cache listener, not just a disconnect().
The version that does work without ceremony is the one that owns a real resource rather than DOM:
chart_controller.js ends with disconnect() { this.chart?.destroy() }, and
reveal_controller.js and ai_stream_controller.js both close with this.observer?.disconnect().
An IntersectionObserver or a Chart.js instance is not in the DOM, so the snapshot cannot capture
it, and disconnect is the only thing between you and one live observer per page you visited.
Worth naming the thing that looks like a leak and is not. clipboard_controller.js schedules
setTimeout(..., 1500) and never clears it. Navigate within a second and a half and that callback
fires against a detached element and writes textContent to a node nobody can see. No error, no
retained page, nothing to fix. A timer that only touches its own subtree is not the problem; a timer
that polls the server or touches window is.
Connect runs twice and the password comes back empty
Restoration visits are the second thing Turbo does to your lifecycle. Visit#loadCachedSnapshot
renders the cached snapshot with isPreview set from shouldIssueRequest(), and Turbo marks that
state by putting data-turbo-preview on <html>. The fresh response then renders over it. One press
of the back button, two connect() calls, one disconnect() between them, exactly as the Stimulus
docs promise: "two calls to a controller's connect() method will always be separated by one call to
disconnect()".
Which means connect() has to be idempotent, and more than that, it has to re-derive state rather
than trust the markup it finds. PageSnapshot#clone is specific about one field type:
for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) {
clonedPasswordInput.value = ""
}
Every password input in a cached page comes back blank. Now look at what password_strength_controller.js
does on connect:
connect() {
this.validate()
}
Two words that keep the page honest. The is-valid classes on the rule chips were set with
classList.toggle, so they survive cloneNode(true); submitTarget.disabled = false reflects to the
attribute, so that survives too. Without that validate() call, a restored page would show six green
rules and an enabled submit button above an empty password field. With it, the controller reads the
actual input values and rebuilds every chip from scratch, and the cache cannot lie to it.
initialize() is the callback that would seem to solve this and does not. Stimulus runs it "once,
when the controller is first instantiated", and a Turbo page render builds a whole new body, so a
whole new instance, so initialize() runs again anyway. Where it stays genuinely once is an element
that survives navigation rather than being rebuilt: one moved around the DOM, one whose
data-controller attribute is toggled off and back on, or one marked data-turbo-permanent, which
Turbo transfers between pages by ID. None of the 25 controllers defines initialize(), and that is
the right default. Put setup in connect() and pay the cost of it running twice.
Testing Stimulus controllers without lying to yourself
Testing is the query with the least honest material behind it, so here is the state of things. The
Stimulus handbook has eight chapters and the reference has eight pages, and not one of them is about
testing. There is no official test harness, no Stimulus::TestCase, nothing to import.
What people do instead is jsdom, through Jest or Vitest, mounting the controller on a fragment of
HTML and asserting on the DOM afterwards. That works, and it works best on exactly the controllers
that need it least. jsdom's own README names what it will not do: "Layout: the ability to calculate
where elements will be visually laid out as a result of CSS, which impacts methods like
getBoundingClientRects() or properties like offsetTop", with dummy behaviors that return zeros.
Hold that against onboarding_steps_controller.js, which decides drop position like this:
const rect = over.getBoundingClientRect()
const after = event.clientX > rect.left + rect.width / 2
over.parentNode.insertBefore(this.dragging, after ? over.nextSibling : over)
Under jsdom every field on rect is zero, so after collapses to event.clientX > 0 and the
controller inserts after the target every single time. A test asserting "dropping card 3 on the right
half of card 1 puts it second" passes while measuring nothing. The same class of hole swallows
reveal_controller.js from the other side: jsdom has no IntersectionObserver (jsdom/jsdom#2032 is
open since 2017, labeled layout, blocked on a layout engine), so the controller takes its own
fallback branch, adds is-visible immediately, and the observer you wanted to test never runs. This
is the shape of failure described in the Rails 7 to 8 upgrade, where
the test suite was structurally incapable of touching the thing it appeared to cover.
The system test nobody in this repository wrote
System tests driving a real browser are where most of this actually gets exercised, and the boilerplate's Gemfile has the gems and the comment saying so:
group :test do
# System (end-to-end) specs driving Turbo/Stimulus in a real browser
gem "capybara"
gem "selenium-webdriver"
end
capybara 3.40.0 and selenium-webdriver 4.49.0, and zero files under spec/system. Twenty-five
controllers, no automated coverage. That is not an endorsement, it is the actual state of a shipping
codebase, and it is the state of most of them.
State the cost plainly, because it is why the directory is empty. A Selenium spec boots a real Chrome, costs seconds rather than milliseconds, and is where flake lives. And it still will not catch the two bugs this post is about, because a system test that visits a page once never presses back into a cached snapshot. If you write one Stimulus test, write the one that navigates away and returns.
The split worth adopting: unit-test the pure logic and leave the DOM alone. PREDICATES in
password_strength_controller.js is a plain object of predicate functions on the class, and
serialize(form) in admin_dirty_form_controller.js is new URLSearchParams(new FormData(form)).toString().
Both are testable in jsdom with no lifecycle, no Turbo and no browser. Drag geometry and scroll
observers are not, and pretending otherwise buys you a green suite and nothing else.
When a controller is the wrong tool
Take the position: reach for a Stimulus controller only when the interaction has to stay on the client. If the answer to the user's click is a new piece of server-rendered HTML, a Turbo Frame is less code and less to keep in sync, and the failure mode is a page that works rather than a page that silently does nothing. A frame that comes back empty has its own diagnosis, and it is a better problem to have than a controller nobody noticed stopped connecting.
Three concrete lines to draw.
A form that submits is a form. admin_dirty_form_controller.js earns its place because it has to
know what the fields looked like at page load, which is client state by definition, and even then its
save() is this.dirtyForm?.requestSubmit(). No fetch, no endpoint, no JSON. The controller
decorates a form submission rather than replacing it.
A list that reorders itself and then posts is a form too. onboarding_steps_controller.js runs
native HTML5 drag and drop with no library, and when you save it writes hidden inputs:
serialize() {
this.orderTarget.replaceChildren()
this.activeKeys().forEach((key) => {
const input = document.createElement("input")
input.type = "hidden"
input.name = "order[]"
input.value = key
this.orderTarget.appendChild(input)
})
}
Nothing persists until the form submits. The server receives order[] and answers with HTML. All the
JavaScript did was let a human express an ordering.
And sometimes the right amount of controller is almost none. dialog_controller.js in its entirety:
export default class extends Controller {
static targets = ["dialog"]
open() { this.dialogTarget.showModal() }
close() { this.dialogTarget.close() }
}
Two methods and one target, because the native <dialog> element already does focus trapping and
Escape to close. reveal_controller.js sits right at the edge of the line: a scroll-triggered fade is
presentation, and CSS scroll-driven animations now cover a good part of it. It stays in JavaScript
because the fallback branch and unobserve after first intersection are easier to read than the
equivalent stylesheet, which is a defensible answer and not obviously the right one.
What would change the position: a design where most interactions are optimistic, latency-sensitive and local, an editor or a canvas rather than a CRUD admin. At that point the round trip a Turbo Frame costs stops being free and client state stops being an accident.
What this post does not cover
Absent here on purpose: stimulus-use, which packages composable behaviours like debouncing and
click-outside and would shorten several controllers above; TypeScript with Stimulus, which has its
own reference page and mostly concerns declaring value types twice; and Stimulus inside Turbo Frames
specifically, where lazy frame loading changes when connect() fires in ways page-level navigation
does not.
Also absent: any benchmark. No figure appears above for what 25 controllers cost to parse or
instantiate, because eager loading through eagerLoadControllersFrom means the number depends
entirely on how your assets are delivered, and a number measured on one deployment would mislead more
than it informs.
Comments
No comments yet. Be the first.