Hotwire In Easy8
This guide documents the Easy8 conventions for Hotwire, especially Turbo Frames and small Stimulus controllers. It is not a generic Hotwire tutorial. Use it when building Rails-rendered browser interactions that do not need a Vue application.
Purpose
Hotwire is the preferred rendering path for dynamic server-rendered screens in Easy8. It keeps the server-side URL, authorization, validation, and rendering logic as the source of truth while allowing partial page updates in the browser.
Use Hotwire to improve an ERB/Rails flow. Use Vue when the feature is a rich client-side application, belongs to an existing Vue module, or needs complex client-side state.
When To Use Hotwire
Good fits:
- tabs where each tab can be rendered by Rails,
- modal or dialog content loaded from Rails,
- small form flows with server validation,
- replacing one part of a page after a link or form action,
- URL-addressable UI state such as
show?tab=history.
Poor fits:
- complex client-side state,
- drag-and-drop heavy applications,
- graph, editor, canvas, or timeline interfaces,
- existing Vue modules where Vue already owns the page,
- behavior that needs a shared client-side store.
Easy8 Turbo Defaults
Turbo and Stimulus are loaded globally from Vite:
app/frontend/entrypoints/turbo.js,app/frontend/entrypoints/stimulus.js.
However, Easy8 layouts disable Turbo globally on the body:
This means Turbo interactions must opt in explicitly:
If data-turbo="true" is missing, the browser may perform a normal navigation instead of a Turbo visit or frame request.
Turbo Frames
Use stable, feature-scoped frame IDs:
<turbo-frame id="collection-tabs">
<%= render partial: "collections/tabs", locals: { collection: } %>
</turbo-frame>
Frame rules:
- a frame response must include the matching
<turbo-frame id="...">, - a full-page response may include the same frame as part of the page,
- keep selected state server-derived from params, validation state, or persisted state,
- avoid nested lazy frames unless there is a clear reason,
- prefer one outer frame for tab content when tabs share URL/history behavior.
Controller Pattern
Use turbo_frame_request? for frame responses and render without the layout:
def show
collection
respond_to do |format|
format.html do
if turbo_frame_request?
render partial: "collections/tabs",
locals: { collection: },
layout: false
else
render
end
end
end
end
Controller rules:
- use
turbo_frame_request?, not manualrequest.headers["Turbo-Frame"]checks, - render
layout: falsefor frame-only responses, - keep the frame branch small and explicit,
- keep authorization and entity lookup identical to the full-page path,
- do not create a second endpoint for the same UI state unless there is a real routing need.
Frame View Pattern
A reusable frame partial can render both the navigation and the selected content:
<turbo-frame id="collection-tabs">
<nav class="tabs">
<% tabs.each do |tab| %>
<%= link_to tab[:label],
collection_path(collection, tab: tab[:name]),
class: tab == selected_tab ? "selected" : nil,
data: {
turbo: true,
turbo_frame: "collection-tabs",
turbo_action: "advance"
} %>
<% end %>
</nav>
<%= render partial: "collections/tabs/#{selected_tab[:name]}",
locals: { collection: } %>
</turbo-frame>
This keeps a single canonical URL for each selected tab, for example collection_path(collection, tab: "history").
Links And History
Use data-turbo-action="advance" when the interaction should update browser history:
<%= link_to tab[:label],
collection_path(collection, tab: tab[:name]),
data: {
turbo: true,
turbo_frame: "collection-tabs",
turbo_action: "advance"
} %>
Omit turbo_action when browser history should not change.
Use _top when an action should leave the current frame context, usually after create, update, delete, or redirecting actions:
<%= link_to "Delete",
item_path(item),
method: :delete,
data: {
turbo_frame: "_top",
confirm: l(:text_are_you_sure)
} %>
Forms And Design System
Easy8 Design System form and button wrappers forward Turbo attributes. Prefer these options instead of hand-writing data-* attributes when using DS helpers.
Form opt-in:
<%= easy_form_for model: record,
url: records_path,
data: { turbo: true } do |f| %>
<%= f.submit "Save", turbo_frame: "_top" %>
<% end %>
Targeted result frame:
<%= ds_button value: "Test connection",
type: :submit,
form: form_id,
form_action: test_connection_path,
form_method: :post,
turbo_frame: "connection-test-result" %>
<turbo-frame id="connection-test-result"></turbo-frame>
Form rules:
- use
_topfor submissions that should redirect or refresh the full page, - target a specific frame only when the response intentionally replaces that frame,
- render server validation errors in the same form partial,
- do not duplicate Rails validation state in JavaScript.
Stimulus
Stimulus is available for small DOM behavior in Rails-rendered pages. Controllers are autoloaded from:
Controller identifiers are derived from the path:
Minimal controller:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
// Initialize small DOM behavior here.
}
}
Use Stimulus for:
- small progressive behavior,
- event handling inside server-rendered HTML,
- replacing inline scripts over time.
Avoid Stimulus for:
- complex application state,
- behavior that belongs in a Vue module,
- server validation or authorization logic,
- large interactive widgets.
Testing
Request specs can exercise frame responses by sending the Turbo-Frame header:
get collection_path(collection, tab: "details"),
headers: { "Turbo-Frame" => "collection-tabs" }
expect(response).to have_http_status(:success)
expect(response.body.strip).to start_with('<turbo-frame id="collection-tabs"')
expect(response.body).not_to include("<body")
Cover the behavior that matters:
- the response contains the matching frame,
- the frame response does not include the full layout,
- links/forms contain the expected Turbo target,
- validation failures render the selected frame content,
- redirects that should leave the frame use
_top.
Use browser validation when the risk is browser-specific:
- clicking sends the expected
Turbo-Framerequest header, - the page does not fully reload,
- browser history changes only when intended,
- frame content is replaced with the expected content.
Anti-Patterns
Avoid these patterns:
- parallel endpoints for the same UI state, such as both
/render_tabandshow?tab=..., - nested lazy frames inside another frame without a clear reason,
- manual Turbo header parsing instead of
turbo_frame_request?, - forgetting
data-turbo="true"in Easy8 layouts, - returning content without the matching
<turbo-frame id="...">, - adding Stimulus where Rails rendering or validation is the correct source of truth,
- adding Vue only to replace a small server-rendered interaction.
Checklist
- Is this feature non-Vue and server-rendered?
- Does the link or form opt into Turbo with
data-turbo="true"? - Does the frame response render without layout?
- Does the response include the matching
<turbo-frame id="...">? - Should this interaction update browser history?
- Should form submission target the frame or
_top? - Is there one canonical URL for this state?
- Is a request spec or browser smoke test covering the frame behavior?
References
- Turbo entrypoint:
app/frontend/entrypoints/turbo.js - Stimulus entrypoint:
app/frontend/entrypoints/stimulus.js - Base layout frame:
easy_engines/easy_extensions/app/views/layouts/base.html.erb - Design System button wrapper:
app/easy_design/design_system/components/button.rb