Hotwire in Easy8¶
Use Hotwire (Turbo Frames and small Stimulus controllers) for Rails-rendered screens that do not need a Vue application. The server stays the source of truth for URLs, authorisation, validation and rendering. Use Vue when the feature is a rich client-side application, belongs to an existing Vue module, or needs complex client-side state, drag and drop, graphs, editors or a shared client-side store.
Good fits for Turbo: tabs rendered by Rails, dialog content loaded from Rails, small forms with server validation, replacing one part of a page after a link or form action, and URL-addressable state such as show?tab=history.
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:
Turbo interactions therefore opt in explicitly:
Without data-turbo="true" the browser performs 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,
- prefer one outer frame for tab content when tabs share URL and history behaviour.
Frames inside design system custom elements¶
A <turbo-frame> inside a Rails-rendered DS custom element (for example ds_card) keeps its original DOM nodes and loaded content. If its slot outlet exists at first mount, moving the frame into it does not cancel or restart an in-flight request. Moving it to another connected outlet also preserves the request.
This applies only to internal slot projection:
- Removing the frame, its host, or the last outlet still cancels an in-flight request.
- If the outlet is absent at first mount (for example a collapsed
ds_fieldsetbody), the content is detached. An eager frame cancels and reloads when the outlet appears; a lazy frame waits. - Ordinary DOM moves, Vue-managed moves (
Teleport, keyed reorders), and outlets in nested async components are not covered. - Other custom elements and Stimulus controllers still receive disconnect/connect callbacks; iframes and scroll positions may reset.
See Slot content in custom elements for slot rules.
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 authorisation and entity lookup identical to the full-page path,
- do not create a second endpoint for the same UI state (for example both
/render_tabandshow?tab=...).
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),
data: {
turbo: true,
turbo_method: :delete,
turbo_frame: "_top",
turbo_confirm: l(:text_are_you_sure)
} %>
Forms and the design system¶
Design System form and button wrappers forward Turbo attributes. When you use DS helpers, pass these options instead of hand-writing data-* attributes.
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.
Stimulus¶
Stimulus is available for small DOM behaviour 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() {
// Initialise small DOM behaviour here.
}
}
Use Stimulus for small progressive behaviour and event handling inside server-rendered HTML. Keep complex state in a Vue module, and validation and authorisation on the server.
Testing¶
Request specs can exercise frame responses by sending the Turbo-Frame header: