LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 3 views

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.

A timeline of one Turbo visit away from a page and back. Leaving the page, turbo:before-cache fires while the toolbar is still in the DOM, the snapshot is cloned with the toolbar in it, and only then does the body get replaced and disconnect remove the toolbar from a DOM nobody will see again. Pressing back, the restored snapshot arrives with that toolbar still in it, connect runs and builds another, and the page ends with two toolbars.

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.

#rails #hotwire

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.