From hotwire-patterns
Provides mental models, decision frameworks, and debugging patterns for Hotwire (Turbo, Stimulus, Hotwire Native) in Rails apps. Activates on common issues like Turbo Frame not updating, morphing problems, or broadcast failures.
How this skill is triggered — by the user, by Claude, or both
Slash command
/hotwire-patterns:hotwire-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Internals-informed patterns for building and debugging Hotwire applications. Core philosophy: **enhance the browser, don't reinvent it**. Start by imagining a JS-free, plain-HTML version of every feature, then compose the separate pages into an integrated UI with Turbo. HTML is the source of truth for state, everywhere.
Internals-informed patterns for building and debugging Hotwire applications. Core philosophy: enhance the browser, don't reinvent it. Start by imagining a JS-free, plain-HTML version of every feature, then compose the separate pages into an integrated UI with Turbo. HTML is the source of truth for state, everywhere.
Hotwire is a cost/benefit dial, not a single approach. Escalate only when the previous rung stops being a good tradeoff:
Different parts of one app can sit on different rungs; it all composes.
fetch, replaces <body> wholesale, and diff-merges <head> children (identical nodes untouched → assets not re-parsed; that's the speed win). Order of head children isn't preserved and doesn't matter.<head>. Modern async scripts + HTTP/2 remove old reasons for body-bottom scripts.data: {turbo_method: :delete} for non-GET links; data: {turbo_confirm: "..."} for confirm dialogs (works on forms and non-GET links; add turbo_method: :get to use on plain links). Customize globally via Turbo.config.forms.confirm = (message, formEl, submitter) => Promise<boolean>.turbo:submit-start / turbo:submit-end events carry event.detail.formSubmission.submitter — use for loaders.render :new, status: :unprocessable_entity). Turbo requires a redirect on success; it renders 4xx bodies happily.Turbo.visit(location) is the programmatic navigation entry point; Turbo.visit(location, { frame }) navigates a frame.advance (link click), replace (redirect after form submit), restore (history). This matters for morphing (below).X-Sec-Purpose: prefetch header identifies prefetches server-side if you must special-case.Mental model: a page within a page. A frame runs the same three observers as Drive (link clicks, non-GET links, form submits) but scoped, and updates only itself.
turbo_frame_tag — you don't need to frame the whole target template, just the part you want.data-turbo-frame="_top" on a link/button/form escapes the frame and drives the full page. target: "_top" on the frame tag itself makes everything inside escape by default.dom_id for frame ids everywhere; turbo_frame_tag record uses it under the hood. For "new" records that need per-context frames (e.g., one new-form per kanban column), build custom string ids in a helper — frame ids are global on the page.src: attribute loads content on render (eager) or on scroll into view with loading: "lazy". Great for decomposing heavy pages (dashboards) into independent endpoints — progressive enhancement applied to composition.TurboFrameMissingError + "Content missing" in the UI. Catch turbo:frame-missing to handle it yourself (preventDefault), and consider reporting these to error tracking (ignore 5xx statuses — already logged server-side; get status from event.detail.response.status).data-turbo-permanent elements (must have unique ids) are transplanted as the same live JS objects across updates — listeners and state survive.<script> tags unless data-turbo-eval="false".Custom <turbo-stream action=... target=...> elements that execute instead of render (their connectedCallback interprets the action and removes the tag). Standard actions: append, prepend, replace, update, remove, before, after, refresh (no target). targets= (plural) takes a CSS selector.
new_ticket) redirects on success to a page whose frame is ticket_4 → Content missing. Fix with a create.turbo_stream.erb responding to format.turbo_stream, either with precise actions (append + clear form + prepend notice) or a single turbo_stream.refresh request_id: nil.refresh re-fetches the current page and re-renders via replace or morph. request_id: nil is required when returning refresh in the HTTP response itself — otherwise the frontend recognizes its own request id and ignores it (the dedup mechanism exists so broadcasts don't double-refresh the originating user).Turbo.StreamActions.log = function() { ... this.getAttribute("message") ... } (executes with this = the stream element; attributes aren't parsed into params). Pair with a Ruby helper module included into Turbo::Streams::TagBuilder. Benefits: a constrained vocabulary of UI manipulations (maintainability) and CSP compatibility (no unsafe-inline needed). Prefer app-specific actions over dropping in big libraries like turbo_power.Read references/morphing.md before debugging any morph behavior — it covers the idiomorph algorithm, id maps, and match scoring.
Quick rules:
turbo_refreshes_with method: :morph, scroll: :preserve in the layout head, above yield :head.replace AND the new URL equals the current URL (trailing slash sensitive!). Redirect back to the same path or you silently get replace rendering. Verify with turbo:render → event.detail.renderMethod.data-turbo-permanent survives all updates including frame updates — too blunt when the element must still be frame-updatable. Roll a scoped attribute instead: listen to turbo:before-morph-element and preventDefault() when the element has e.g. data-turbo-prevent-morph. Requires stable ids and identical child structure between old/new so the morpher pairs the elements.turbo_stream_from @record (or a plain string) renders a <turbo-cable-stream-source> with a signed stream name (via to_gid_param, falling back to to_param — that's why strings work).broadcasts_refreshes on the model = after commit callbacks calling broadcast_refresh_later_to. To broadcast to a shared stream on create/update/destroy, write the three callbacks yourself against the stream name.Turbo::Streams::BroadcastStreamJob) and are debounced ~0.5s per thread — multiple saves in one request emit one refresh. For latency-critical paths, enqueue the job directly and skip the debouncer.X-Turbo-Request-Id mechanism.This section covers the mental model — how Stimulus fits the Hotwire picture. For exhaustive controller-authoring patterns (Values API, targetless controllers, mixins, teardown, SOLID, cookbook), use the better-stimulus skill; it's the authority for writing a controller. Read references/stimulus.md here for the design principles (reusability, callbacks, composition via events vs outlets). Core rules:
[name]TargetConnected/Disconnected and [name]ValueChanged callbacks over connect — they make controllers robust to any DOM change source (Turbo, other controllers, anything).connect = wiring only; disconnect = cleanup not handled by target callbacks.event->controller#method, keydown.esc->modal#close, resize@window->x#y (last resort), :stop/:prevent options, data-<controller>-<name>-param → event.params.new endpoint on any watched input change; the server renders the variation. One reusable controller, all form logic in one backend place.MutationObserver on <html> powers everything (controller attach/detach, values, targets, outlets).cloneNode(true) — HTML only; listeners/state don't survive, which is fine if Stimulus reconnects rebuild everything).Turbo.cache.clear() if you mutate state outside forms).data-turbo-temporary (auto-removed before caching; ideal for flash messages), clean up in Stimulus disconnect (fires on snapshot clone), turbo:before-cache listener for global resets, or <meta name="turbo-cache-control" content="no-preview"> / no-cache.data-turbo-visit-direction on <html> (forward/back/none) helps animate view transitions.Read references/hotwire-native.md when building or debugging the mobile apps (setup, Path Configuration, Bridge Components, publishing, native debugging).
One-line mental model: a webview wrapper that injects turbo.js to register a native Turbo adapter; full-page visits get proposed to native code (push/pop screen stacks), frame/stream updates stay pure-web. Different URLs = different screens; mind trailing slashes.
Read references/testing-and-legacy.md for: system-test flakiness (always assert a stable state; Capybara waiting), multi-session collaborative tests (Capybara.using_session, perform_enqueued_jobs wrapping the waiting assertion because broadcasts are debounced, connect_turbo_cable_stream_sources), and the 7-step gradual Turbo adoption plan for legacy apps (start with everything disabled: Turbo.session.drive = false, Turbo.session.history.stop(), Turbo.setFormMode("optin"), then expand frame by frame).
Read references/debugging.md when hunting a Hotwire bug. Highlights: unminify Turbo in the importmap (turbo.js not turbo.min.js); use DOM "Break on" (subtree/attribute/removal) breakpoints to catch Turbo mid-update; key Turbo source landmarks (Navigator#proposeVisit, LinkInterceptor#linkClicked, StreamActions, search "turbo:morph" / shouldMorphPage =); check Turbo-Frame request header for frame issues; filter ActionCable pings in the Network Socket tab with ^(?!.*"type":"ping").*$; Stimulus.debug = true for lifecycle prints; disable cache to rule it out.
npx claudepluginhub maquina-app/rails-claude-code --plugin hotwire-patternsGuides writing, reviewing, and refactoring Hotwire-powered Rails code with patterns for Turbo Drive, Turbo Frames, Turbo Streams, Turbo 8 morphing, and Stimulus controllers.
Implements Hotwire features with Turbo Drive, Turbo Frames, and Turbo Streams in Rails 8, covering morphing, broadcasts, lazy loading, and real-time updates.
Creates Hotwire UIs with progressive enhancement in Rails: Stimulus controllers, Turbo Frame markup, Turbo Stream responses, ActionCable broadcasts, and degraded mode verification.