Skip to content

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:

<body data-turbo="false">

This means Turbo interactions must opt in explicitly:

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

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

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 _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,
  • do not duplicate Rails validation state in JavaScript.

Stimulus

Stimulus is available for small DOM behavior 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() {
    // 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-Frame request 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_tab and show?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