Skip to content

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:

<body data-turbo="false">

Turbo interactions therefore opt in explicitly:

<%= link_to "Details",
            details_path,
            data: {
              turbo: true,
              turbo_frame: "details-frame"
            } %>

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_fieldset body), 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 manual request.headers["Turbo-Frame"] checks,
  • render layout: false for 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_tab and show?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").

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 _top for 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:

app/frontend/src/controllers/**/*_controller.{js,ts}

Controller identifiers are derived from the path:

app/frontend/src/controllers/admin/user_menu_controller.js -> admin--user-menu

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:

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")