Our Rails CLAUDE.md guide covers Hotwire in a handful of paragraphs — enough to tell Claude Code that a project uses Turbo Frames instead of a SPA framework, not enough to stop it from writing a Stimulus controller that misses the [name]TargetConnected callback ordering, or a Turbo Stream response that duplicates DOM update logic a Stimulus controller already owns. That gap matches a pattern we’ve hit before with Kubernetes and Terraform: a subsection inside a broader framework guide is fine until the subsection covers a genuinely separate skill, at which point it needs its own template. Hotwire’s frontend half — Stimulus and Turbo specifically — is that case.
We checked what’s live against the Hotwire team’s own reference docs (stimulus.hotwired.dev, turbo.hotwired.dev) rather than tutorial content, because most of the public code Claude Code trained on predates the Values API’s type-coercion rules and the Outlets API entirely. The result is a template focused on the failure modes that don’t throw errors — they just produce Stimulus controllers that work in the browser console and misbehave in production.
Browse the Rails + Hotwire + Tailwind rule set and more real-world CLAUDE.md examples in our gallery.
Why “Use Stimulus” Isn’t a Rule
Tell Claude Code a project “uses Hotwire” and it will write something that resembles Stimulus — because Stimulus’s public surface (data-controller, connect()) is small and shows up constantly in training data. What it won’t reliably get right, because these are the parts most tutorials skip or get wrong themselves:
- Skips
static targets/static valuesdeclarations and reaches fordocument.querySelectorinside the controller instead, which defeats the entire point of Stimulus’s declarative target system and breaks the moment the controller is reused on a second element on the same page - Ignores Boolean value coercion. The Values API decodes
"0"and"false"(as strings) tofalse— Claude Code frequently writesif (this.enabledValue)assuming JavaScript truthiness on a raw data attribute, which passes for"true"but silently breaks for any other truthy-looking string - Assumes controller callbacks fire in write order.
[name]TargetConnected()fires before the controller’s ownconnect()— code that readsthis.hasFooTargetinsideconnect()and expects it to reflect a target added by that same render pass can hit ordering bugs Claude Code won’t anticipate - Duplicates Turbo Stream behavior in a Stimulus controller, writing a
fetch()call inside a controller action to update the DOM instead of letting a Turbo Stream response (from the same form submission) handle it — this is the single most common Hotwire anti-pattern in AI-generated code, because it’s the pattern every non-Hotwire JS framework teaches by default - Wires Outlets without an existence check.
this.userStatusOutletthrows “Missing outlet element” if the target controller isn’t on the page yet; without an explicit rule, Claude Code accesses outlets directly instead of guarding withthis.hasUserStatusOutletfirst - Puts
data-*-valueattributes on the wrong element. Values, like targets, must live on the same element asdata-controller— Claude Code sometimes places them on a child element by analogy with how targets can nest, which is not how the Values API works
None of these fail a linter. Stimulus has no compiler; a controller with a misplaced value attribute or a missing existence check runs fine until the specific runtime condition it mishandles occurs.
What’s Actually Current in Hotwire (2026)
- The Values API has five types with real coercion rules, not just “read the attribute as a string”:
String,Number(viaNumber(), underscores stripped),Boolean(string-based,"0"/"false"→false), andArray/Object(both viaJSON.parse/JSON.stringify). Declare a default with the expanded syntax —{ type: String, default: '/dashboard' }— rather than checking forundefinedin the controller body. - Outlets are a first-class, stable API for controller-to-controller communication, not a workaround.
static outlets = ["user-status"]plusdata-chat-user-status-outlet=".online-user"generates five properties (hasUserStatusOutlet,userStatusOutlet,userStatusOutlets,userStatusOutletElement,userStatusOutletElements) and two lifecycle callbacks (userStatusOutletConnected/Disconnected). Namespaced controllers (admin--user-status) drop the delimiters in the outlet property name —adminUserStatusOutlet, notadmin__UserStatusOutlet. - Turbo Streams have eight actions, not the commonly cited seven:
append,prepend,replace,update,remove,before,after, andrefresh(added for Turbo 8’s page-refresh-with-morph flow).updatereplaces inner content while preserving the target element’s own event handlers;replaceswaps the whole element, including anything Stimulus attached to it. - Turbo Frames and Stimulus controllers can conflict on reconnect. Navigating a
<turbo-frame>replaces its contents, which runsdisconnect()on every Stimulus controller inside the old frame andconnect()on controllers in the new content — including a new instance, not the same controller object. Any state a controller kept as instance properties (not Values) is gone after a frame navigation, which is the source of a specific bug class: “the counter resets after I click a Turbo Frame link” is almost always a controller holding state that should have been a Value. - Turbo 8’s Morphing (
turbo-refresh-method="morph") is opt-in and changes reconnect behavior. With page refreshes via broadcast + morph, DOM nodes that didn’t change are not replaced, so their Stimulus controllers don’t disconnect/reconnect at all — the opposite failure mode from frame navigation, where code that assumed a freshconnect()on every update now doesn’t get one.
A CLAUDE.md Template for Hotwire Projects
# CLAUDE.md — Hotwire (Turbo + Stimulus) Instructions
## Stack
- **Backend**: Rails 8.0 (or: framework name + version)
- **Frontend**: Hotwire — Turbo Drive/Frames/Streams + Stimulus, no SPA framework, no separate build for interactivity
- **Turbo mode**: standard navigation (or: Morphing enabled via `turbo-refresh-method="morph"` — see Reconnect Behavior below)
- **Stimulus controllers**: `app/javascript/controllers/`, one per file, `kebab-case` filenames matching the `data-controller` value
- **No jQuery. No direct DOM manipulation outside a Stimulus controller's own target/element scope.**
## Decision Rule: Turbo Stream vs. Stimulus Controller
Ask this before writing any UI-update code:
- Does the update need data from the server? → **Turbo Stream** (form response or broadcast). Do not `fetch()` it manually inside a controller.
- Is it purely client-side state/behavior (toggle, drag, keyboard shortcut, client-side validation)? → **Stimulus controller**.
- Never write a `fetch()` call inside a Stimulus `connect()` or action method to replace DOM content that a form submission could deliver as a Turbo Stream response instead.
## Stimulus Controller Rules
- Every controller MUST declare `static targets`, `static values`, and `static outlets` up front — no `document.querySelector` inside a controller as a substitute for a target.
- Value types must be explicit, including defaults where the attribute may be absent:
```javascript
static values = {
open: { type: Boolean, default: false },
endpoint: { type: String, default: "/api/status" },
retryCount: Number
}
- Boolean values decode
"0"and"false"(as strings) tofalse— never assume raw JS truthiness on a value read from a data attribute without going throughthis.xValue. data-*-valueanddata-*-targetattributes belong on the SAME element asdata-controller. Do not place them on child elements.- Guard every outlet access with the existence check:
if (this.hasUserStatusOutlet) { this.userStatusOutlet... }. Accessing an outlet property without one throws. [name]TargetConnected()fires before the controller’s ownconnect(). Do not writeconnect()logic that assumes a target added in the same render pass isn’t visible yet — it already is.- Do not keep meaningful state as a bare instance property (
this.count = 0) if the controller can disconnect/reconnect (e.g., inside a Turbo Frame). Use a Stimulus Value instead — it survives navigation via the DOM attribute.
Turbo Frame / Stream Rules
- Wrap partial content needing independent navigation in
<turbo-frame id="...">, matched withdom_id()(or equivalent) on both the link target and the frame definition. - Turbo Stream responses use these actions:
append,prepend,replace,update,remove,before,after,refresh. Useupdate(preserves event handlers/attached behavior) overreplace(destroys and recreates the element, including anything Stimulus attached to it) unless a full swap is actually required. - Reuse the same server-side partial/template for the initial page render and the Turbo Stream response. Do not create a second client-side template for stream updates — this is the #1 source of UI drift between first load and live updates.
- Broadcast model changes from the model layer (e.g.,
broadcasts_to), not with inline stream templates written in controllers.
Reconnect Behavior (state this explicitly per project)
- Standard Turbo navigation (frame or drive): old DOM is replaced, all contained Stimulus controllers disconnect and a NEW instance connects on the new content. Instance properties do not survive; Values (stored as DOM attributes) do.
- Turbo 8 Morphing (if enabled): unchanged DOM nodes are NOT replaced, so their controllers do NOT disconnect/reconnect on a page refresh. If code relies on
connect()running on every update, Morphing breaks that assumption — say explicitly in CLAUDE.md whether Morphing is on.
Testing
- System specs (Capybara) should assert on the rendered Turbo Stream result, not on JavaScript internals — Stimulus controllers are DOM-driven and Capybara exercises the real browser event loop.
- For controller-only unit tests, mount the controller against a fixture DOM fragment; do not mock
this.xTarget— Stimulus resolves targets from the live DOM tree.
## Path-Scoped Rules for Larger Codebases
Frontend-specific Hotwire rules don't need to load into context during a pure backend (model/controller/service) session. If the project uses Claude Code's directory-scoped `CLAUDE.md` support, put the Stimulus/Turbo rules above in `app/javascript/CLAUDE.md` and keep the root `CLAUDE.md` to the stack declaration and the Turbo Stream vs. Stimulus decision rule — that one decision matters everywhere a controller action returns HTML, not just inside `app/javascript/`.
## Comparison: Hotwire Output Without vs. With a Dedicated CLAUDE.md
| Issue | Without CLAUDE.md | With CLAUDE.md |
|---|---|---|
| Target/value declarations | Falls back to `document.querySelector` inside the controller | Declares `static targets`/`static values` explicitly |
| Boolean values | Assumes JS truthiness on the raw attribute string | Reads via `this.xValue`, aware `"0"`/`"false"` decode to `false` |
| DOM updates after a form submit | Writes a `fetch()` call inside a Stimulus controller | Lets the Turbo Stream response handle it |
| Outlet access | Accesses `this.xOutlet` directly, throws if missing | Guards with `this.hasXOutlet` first |
| State across navigation | Stores state as `this.count`, lost on frame reconnect | Stores state as a Stimulus Value (DOM-backed) |
| Stream action choice | Defaults to `replace` for every update | Uses `update` to preserve attached behavior, `replace` only when needed |
## Why This Matters More With Morphing
Turbo 8's Morphing feature is the newest source of Hotwire-specific AI confusion, because it inverts the reconnect assumption every other part of Turbo trained Claude Code to make. Under standard Turbo navigation, a controller disconnecting and reconnecting on every DOM swap is the norm — code that relies on fresh state from `connect()` works. Under Morphing, unchanged nodes survive a broadcast update without disconnecting, so the same code that worked under standard navigation can silently stop re-initializing. A CLAUDE.md that states which reconnect model the project is actually running — and Claude Code's training data has almost no exposure to Morphing yet, since it shipped well after most public Hotwire code was written — closes a gap that no amount of generic "use Stimulus" instruction fixes.
## Related Guides
- [CLAUDE.md for Ruby on Rails Projects](/blog/claude-md-ruby-on-rails-projects-2026/) — the backend half of this stack: ActiveRecord, RSpec, soft deletes, and toolchain rules
- [Rails + Hotwire + Tailwind rules](/rules/rails-ruby-hotwire/) in our gallery
- Browse the full [CLAUDE.md and AGENTS.md gallery](/rules/)