NYC
skills/dchuk/rails_ai_agents/hotwire-patterns

hotwire-patterns

SKILL.md

Hotwire Patterns for Rails 8

Overview

Hotwire = HTML Over The Wire. Build modern web apps without writing much JavaScript.

Component Purpose Use Case
Turbo Drive SPA-like navigation Automatic, no code needed
Turbo Frames Partial page updates Inline editing, tabbed content
Turbo Streams Real-time DOM updates Live updates, flash messages
Stimulus JavaScript sprinkles Toggles, forms, interactions

When to Use Each Pattern

Scenario Pattern
Inline edit Turbo Frame
Form submission with multiple updates Turbo Stream
Real-time feed Turbo Stream + ActionCable
Toggle visibility Stimulus
Form validation Stimulus
Infinite scroll Turbo Frame + lazy loading
Modal dialogs Turbo Frame
Flash messages Turbo Stream

References

Turbo Frames

Basic Frame

<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts" do %>
  <%= render @posts %>
  <%= link_to "Load More", posts_path(page: 2) %>
<% end %>

Inline Editing

<%# _post.html.erb %>
<%= turbo_frame_tag dom_id(post) do %>
  <article>
    <h2><%= post.title %></h2>
    <%= link_to "Edit", edit_post_path(post) %>
  </article>
<% end %>

<%# edit.html.erb %>
<%= turbo_frame_tag dom_id(@post) do %>
  <%= form_with model: @post do |f| %>
    <%= f.text_field :title %>
    <%= f.submit "Save" %>
    <%= link_to "Cancel", @post %>
  <% end %>
<% end %>

Lazy Loading

<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
  <p>Loading comments...</p>
<% end %>

Turbo Streams

From Controller

<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts", @post %>
<%= turbo_stream.update "flash", partial: "shared/flash" %>

Stream Actions

turbo_stream.append "posts", @post           # Add to end
turbo_stream.prepend "posts", @post          # Add to start
turbo_stream.replace dom_id(@post), @post    # Replace element
turbo_stream.update dom_id(@post), @post     # Replace inner HTML
turbo_stream.remove dom_id(@post)            # Remove element

Flash Messages with Streams

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  after_action :flash_to_turbo_stream, if: -> { request.format.turbo_stream? }

  private

  def flash_to_turbo_stream
    flash.each do |type, message|
      flash.now[type] = message
    end
  end
end

Stimulus Controllers

Basic Controller

// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  toggle() {
    this.contentTarget.classList.toggle("hidden")
  }
}
<div data-controller="toggle">
  <button data-action="toggle#toggle">Toggle</button>
  <div data-toggle-target="content">Hidden content</div>
</div>

Form Controller

// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["submit"]

  enableSubmit() {
    this.submitTarget.disabled = false
  }

  disableSubmit() {
    this.submitTarget.disabled = true
  }
}

Testing Hotwire

Turbo Stream Response Tests

# test/controllers/posts_controller_test.rb
require "test_helper"

class PostsControllerTest < ActionDispatch::IntegrationTest
  setup do
    sign_in users(:one)
  end

  test "create returns turbo stream response" do
    post posts_path,
      params: { post: { title: "Test" } },
      headers: { "Accept" => "text/vnd.turbo-stream.html" }

    assert_response :success
    assert_equal "text/vnd.turbo-stream.html", response.media_type
    assert_includes response.body, "turbo-stream"
  end

  test "create with HTML format redirects" do
    post posts_path, params: { post: { title: "Test" } }

    assert_redirected_to post_path(Post.last)
  end
end

System Tests (with JavaScript)

# test/system/posts_test.rb
require "application_system_test_case"

class PostsSystemTest < ApplicationSystemTestCase
  setup do
    @user = users(:one)
    sign_in @user
  end

  test "updates post inline with Turbo Frame" do
    post = posts(:one)

    visit posts_path
    within("#post_#{post.id}") do
      click_link "Edit"
      fill_in "Title", with: "Updated"
      click_button "Save"
    end

    assert_text "Updated"
    assert_no_text post.title
  end

  test "adds comment with Turbo Stream" do
    post = posts(:one)

    visit post_path(post)
    fill_in "Comment", with: "Great post!"
    click_button "Add Comment"

    within("#comments") do
      assert_text "Great post!"
    end
  end
end

Debugging Tips

  1. Frame not updating? Check frame IDs match exactly
  2. Stream not working? Verify Accept header includes turbo-stream
  3. Stimulus not firing? Check controller name matches file name
  4. Events not working? Use data-action="event->controller#method"

Checklist

  • Identify update scope (full page vs partial)
  • Choose pattern (Frame vs Stream vs Stimulus)
  • Implement server response
  • Add client-side markup
  • Test with and without JavaScript
  • Write system test for interactive behavior
  • All tests GREEN
Weekly Installs
2
First Seen
7 days ago
Installed on
gemini-cli2
opencode2
antigravity2
codex2
windsurf2
kiro-cli2