Hotwire Series · 4 of 6
The Stimulus boundary
Where Stimulus came from, why a controller with twenty fields and a state machine signals you have crossed the line, and the rule seniors use to decide whether to stay in Stimulus or graduate to a real frontend framework.
Where this rule comes from
Stimulus was open-sourced by Sam Stephenson and the Basecamp team in 2018. The design premise was that most JavaScript on most web pages is small behavioral glue: open a dropdown, copy to clipboard, animate a panel, validate an input client-side. The dominant tools at the time (React, Vue, Angular) assumed JavaScript owned the page. Stimulus assumed the server owned the page, and JavaScript layered on top.
The mechanics reflect that premise. A Stimulus controller is attached to a DOM element via data-controller="foo". It can read inputs (data-foo-target), write outputs (mutating the DOM directly), and respond to events (data-action="click->foo#bar"). It does not render templates, manage virtual DOM, or own application state.
That smaller scope is the feature. A team that adopts Stimulus is choosing not to manage two state models: the server's authoritative data and the client's mirror of it. The server renders the HTML, the controller animates and validates the HTML, and when something has to persist, a form submission round-trips to the server.
The rule that follows is straightforward to state and hard to feel. If state has to live somewhere other than the DOM, you are past Stimulus's sweet spot. The next sections are about what crossing that line looks like, and where you go when you do.
The anti-pattern: Stimulus controllers as components
A team that knows React reaches for Stimulus and writes it as if it were React. Each controller has fifteen target references, ten value bindings, a connect() method that initializes a state machine, computed values derived from other values, and event listeners that fire across other controllers via global pub-sub.
// app/javascript/controllers/checkout_controller.js — anti-pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"addressForm", "shippingMethod", "paymentMethod",
"subtotal", "tax", "shipping", "discount", "total",
"stepIndicator", "submitButton", "errorPanel",
"summaryEmail", "summaryAddress", "summaryItems"
]
static values = {
step: { type: Number, default: 0 },
cart: Object,
user: Object,
addresses: Array,
promoCode: String,
pendingTax: Boolean,
canCheckout: Boolean
}
connect() {
this.recomputeTotals()
this.updateStepIndicator()
this.subscribeToCartChanges()
this.boundOnCartChange = this.onCartChange.bind(this)
document.addEventListener("cart:changed", this.boundOnCartChange)
}
// ...80 more lines of methods that compute, mutate, and reconcile state
} Three things are wrong with this. The state lives in the controller's JavaScript fields, not in the DOM. The reconciliation logic between cart, user, addresses, and the visible totals is now in two places: the server (where it has to be authoritative for charging the card) and the client (where it has to render fast). Bugs come from these two implementations diverging.
The second symptom is cross-controller event bus. Once you have a cart:changed custom event, you have re-invented Redux's action dispatcher, badly. Stimulus does not give you message ordering guarantees, type safety, or replayable state. You are paying React's complexity tax without React's tooling.
The third symptom is the connect() initializer that runs ten things in sequence and depends on order. Stimulus controllers can reconnect (when Turbo morphs the DOM, when a turbo-frame swaps in new HTML), and a controller that has thirty bytes of internal state and an order-dependent setup will re-initialize wrong, lose state, or duplicate event listeners.
The shape Stimulus actually fits
A well-fitting Stimulus controller does one thing, reads state from the DOM, and writes state back to the DOM. Examples that have been shipping in 37signals products for years:
// Toggle a panel open and closed
export default class extends Controller {
static targets = ["panel"]
toggle() {
this.panelTarget.classList.toggle("hidden")
}
} // Copy a value to the clipboard
export default class extends Controller {
static targets = ["source", "feedback"]
async copy() {
await navigator.clipboard.writeText(this.sourceTarget.value)
this.feedbackTarget.textContent = "Copied"
setTimeout(() => { this.feedbackTarget.textContent = "" }, 1500)
}
} // Auto-resize a textarea as the user types
export default class extends Controller {
resize() {
this.element.style.height = "auto"
this.element.style.height = this.element.scrollHeight + "px"
}
} Each controller is under 20 lines. Each has one or two targets. None of them manage state that persists beyond the visible DOM. None of them coordinate with other controllers via custom events. If a controller starts looking different from these shapes, it is a signal to step back.
Signs you have crossed the boundary
The line is not always sharp, but a few signals show up reliably:
- State persists across navigations. An undo history that survives a Turbo Drive visit. A multi-step wizard where step state has to be remembered between page transitions. A client-side cache of data that the user pre-loaded.
- Multiple controllers coordinate. When changing a value in controller A has to update controller B, and controller B has to recompute against controller C, you have built an event-driven app inside Stimulus and the wiring will rot.
- Computed values from server data. A live total that depends on three server-rendered numbers. A character count that has to stay under a limit. These are bearable at small scale; at large scale they become reconciliation bugs.
- Offline-first behavior. The app needs to work without a network, queue mutations, sync when back online. Stimulus has no concept of this; you will end up reinventing IndexedDB, conflict resolution, and a service worker.
- Real-time multi-user editing. Two users on the same document, seeing each other's cursors, with operational transforms keeping the state consistent. Hotwire broadcasts are not the same shape as this problem.
None of these are inherent flaws in Stimulus. They are signs that the problem you are solving is shaped differently from what Stimulus was designed for. The next section is about where to go when you see them.
Where to go when you cross it
The graduation path from Hotwire, when one is needed, looks like a ladder. Each step adds complexity in exchange for handling a more JavaScript-heavy problem shape.
Inertia.js is the smallest step up. Rails controllers still own routing and data fetching. Views become React (or Vue, or Svelte) components instead of ERB partials. The server renders the page shell, Inertia hands props to the component, and client-side navigation gets you SPA-style transitions while keeping the controller model. Jobboard apps that need richer UI than Hotwire offers (search-as-you-type with complex filtering, drag-and-drop with persistent state) often land here.
A full SPA with a Rails JSON API is the next step. The frontend is its own application, the backend is only a JSON server. You pay for two repositories of code that have to evolve together, two deployment pipelines, two debug environments. The reward is full ownership of the client-side experience. This is the right shape for apps where the UX itself is the product (Notion, Linear, Figma) and the backend is in service of it.
Hotwire Native sits to the side, not above. It lets you reuse server-rendered HTML inside a native iOS or Android shell, with native chrome and gestures. Useful when "I want a mobile app" appears on the roadmap and you do not want to maintain three codebases.
The principle at play
Tool fit, not tool loyalty. Stimulus is excellent for the problems it was designed for: behavior-on-top-of-server-rendered-HTML, single-purpose controllers, DOM-as-state. It is not designed for client-owned state, multi-controller coordination, or offline-capable apps. Recognising the line keeps you out of the "Stimulus controller as React component" trap that wastes weeks and gets you back to where you should have started.
The senior version of this judgment is not that Stimulus is good or bad. It is the willingness to step out of Stimulus the moment the problem shape demands something else, and to step back in the moment that demand disappears. Apps with simple pages, complex pages, and a few really-complex screens can use Stimulus for the first, Inertia for the middle, and a small embedded SPA for the last. That kind of pragmatism is what separates senior frontend judgment in a Rails app from "we use Hotwire" or "we use React" as identity.
Practice exercise
- List the Stimulus controllers in a Rails app you maintain. For each, count: number of targets, number of values, number of methods, lines of code.
- Any controller with more than 50 lines is suspect. Open it. Where does its state live? If the answer is "in JavaScript fields that mirror server data," you have a candidate for extraction or rewrite.
- Find a controller that uses custom events to coordinate with another controller. Trace the event flow. If two controllers fire and listen to each other through more than one event, you have implicit state that the team will eventually have to make explicit.
- Pick the most complex UI on the roadmap for the next quarter. Sketch how it would look in Stimulus, in Inertia + React, and in a small embedded SPA. The right tool is rarely the one you used last time; it is the one whose shape matches the problem.