| name | Turbo & Hotwire Patterns |
| description | Complete guide to Hotwire implementation including Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus controllers in Rails applications. Use this skill when implementing real-time updates, partial page rendering, or JavaScript behaviors in Rails views. |
Turbo & Hotwire Patterns Skill
This skill provides comprehensive guidance for implementing Hotwire (Turbo + Stimulus) in Ruby on Rails applications.
When to Use This Skill
- Implementing partial page updates
- Adding real-time features
- Creating Turbo Frames and Streams
- Writing Stimulus controllers
- Debugging Turbo-related issues
External References
- Turbo: https://turbo.hotwired.dev/
- Stimulus: https://stimulus.hotwired.dev/
Hotwire Stack Overview
Hotwire
├── Turbo
│ ├── Turbo Drive — Full page navigation without reload
│ ├── Turbo Frames — Partial page updates
│ └── Turbo Streams — Real-time updates over WebSocket/HTTP
│
└── Stimulus — Lightweight JavaScript controllers
Turbo Drive
Automatically converts all link clicks and form submissions into AJAX requests.
Disabling for Specific Links
<%# Skip Turbo Drive for this link %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>
<%# Skip for form %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
Progress Bar
/* Customize Turbo progress bar */
.turbo-progress-bar {
background-color: #4f46e5;
height: 3px;
}
Turbo Frames
Partial page updates within a frame boundary.
Basic Frame
<%# app/views/tasks/index.html.erb %>
<%= turbo_frame_tag "tasks_list" do %>
<% @tasks.each do |task| %>
<%= render task %>
<% end %>
<%= link_to "Load more", tasks_path(page: @next_page) %>
<% end %>
Frame Navigation
<%# Links within frame navigate inside frame %>
<%= turbo_frame_tag dom_id(@task) do %>
<h3><%= @task.title %></h3>
<%= link_to "Edit", edit_task_path(@task) %>
<% end %>
<%# Edit form replaces frame content %>
<%# app/views/tasks/edit.html.erb %>
<%= turbo_frame_tag dom_id(@task) do %>
<%= render "form", task: @task %>
<% end %>
Breaking Out of Frame
<%# Target another frame %>
<%= link_to "Details", task_path(@task), data: { turbo_frame: "task_detail" } %>
<%# Target the whole page %>
<%= link_to "Full Page", task_path(@task), data: { turbo_frame: "_top" } %>
Lazy Loading Frames
<%# Load content when frame becomes visible %>
<%= turbo_frame_tag "comments",
src: task_comments_path(@task),
loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
Frame with Different Source
<%# Frame that loads from different URL %>
<%= turbo_frame_tag "sidebar",
src: sidebar_path,
target: "_top" do %>
<p>Loading sidebar...</p>
<% end %>
Turbo Streams
Real-time DOM updates via WebSocket or HTTP responses.
Stream Actions
<%# Append to container %>
<%= turbo_stream.append "tasks" do %>
<%= render @task %>
<% end %>
<%# Prepend to container %>
<%= turbo_stream.prepend "tasks" do %>
<%= render @task %>
<% end %>
<%# Replace specific element %>
<%= turbo_stream.replace dom_id(@task) do %>
<%= render @task %>
<% end %>
<%# Update contents (not replace element) %>
<%= turbo_stream.update "task_count" do %>
<%= @tasks.count %>
<% end %>
<%# Remove element %>
<%= turbo_stream.remove dom_id(@task) %>
<%# Before/After %>
<%= turbo_stream.before dom_id(@task) do %>
<div class="alert">Task updated!</div>
<% end %>
<%= turbo_stream.after dom_id(@task) do %>
<div class="related">Related tasks...</div>
<% end %>
Stream Response from Controller
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def create
@task = current_account.tasks.build(task_params)
respond_to do |format|
if @task.save
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to @task }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"task_form",
partial: "form",
locals: { task: @task }
)
end
format.html { render :new }
end
end
end
def destroy
@task = current_account.tasks.find(params[:id])
@task.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@task)) }
format.html { redirect_to tasks_path }
end
end
end
<%# app/views/tasks/create.turbo_stream.erb %>
<%= turbo_stream.prepend "tasks" do %>
<%= render @task %>
<% end %>
<%= turbo_stream.replace "task_form" do %>
<%= render "form", task: Task.new %>
<% end %>
<%= turbo_stream.update "tasks_count" do %>
<%= current_account.tasks.count %>
<% end %>
Broadcast Streams (Real-time)
# app/models/task.rb
class Task < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "tasks" }
after_update_commit -> { broadcast_replace_to "tasks" }
after_destroy_commit -> { broadcast_remove_to "tasks" }
# Or with custom stream name
after_create_commit -> {
broadcast_prepend_to [account, "tasks"],
target: "tasks_list",
partial: "tasks/task"
}
end
<%# Subscribe to stream in view %>
<%= turbo_stream_from @account, "tasks" %>
<div id="tasks_list">
<%= render @tasks %>
</div>
Stimulus Controllers
Lightweight JavaScript behaviors.
Basic Controller
// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("Hello controller connected!")
}
greet() {
alert("Hello, Stimulus!")
}
}
<div data-controller="hello">
<button data-action="click->hello#greet">Greet</button>
</div>
Targets
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results", "count"]
search() {
const query = this.inputTarget.value
fetch(`/search?q=${query}`)
.then(response => response.text())
.then(html => {
this.resultsTarget.innerHTML = html
})
}
clear() {
this.inputTarget.value = ""
this.resultsTarget.innerHTML = ""
}
// Check if target exists
updateCount() {
if (this.hasCountTarget) {
this.countTarget.textContent = this.resultsTarget.children.length
}
}
}
<div data-controller="search">
<input data-search-target="input"
data-action="input->search#search">
<button data-action="click->search#clear">Clear</button>
<span data-search-target="count"></span>
<div data-search-target="results"></div>
</div>
Values
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
seconds: { type: Number, default: 60 },
url: String,
autoStart: { type: Boolean, default: false }
}
connect() {
if (this.autoStartValue) {
this.start()
}
}
start() {
this.remaining = this.secondsValue
this.timer = setInterval(() => this.tick(), 1000)
}
tick() {
if (this.remaining > 0) {
this.remaining--
this.element.textContent = this.remaining
} else {
this.finish()
}
}
finish() {
clearInterval(this.timer)
if (this.hasUrlValue) {
window.location.href = this.urlValue
}
}
// Called when value changes
secondsValueChanged() {
this.remaining = this.secondsValue
}
disconnect() {
clearInterval(this.timer)
}
}
<div data-controller="countdown"
data-countdown-seconds-value="30"
data-countdown-url-value="/timeout"
data-countdown-auto-start-value="true">
30
</div>
Actions
// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit"]
// Default action (no method specified)
submit(event) {
event.preventDefault()
this.submitTarget.disabled = true
// ... form submission logic
}
// With event options
// data-action="keydown.enter->form#submit"
// data-action="click->form#submit:prevent"
}
<form data-controller="form"
data-action="submit->form#submit">
<input data-action="keydown.enter->form#submit:prevent">
<button data-form-target="submit"
data-action="click->form#validate">
Submit
</button>
</form>
Classes
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = ["open", "closed"]
static targets = ["menu"]
toggle() {
if (this.menuTarget.classList.contains(this.openClass)) {
this.close()
} else {
this.open()
}
}
open() {
this.menuTarget.classList.remove(this.closedClass)
this.menuTarget.classList.add(this.openClass)
}
close() {
this.menuTarget.classList.remove(this.openClass)
this.menuTarget.classList.add(this.closedClass)
}
}
<div data-controller="dropdown"
data-dropdown-open-class="block"
data-dropdown-closed-class="hidden">
<button data-action="click->dropdown#toggle">Menu</button>
<div data-dropdown-target="menu" class="hidden">
Menu content
</div>
</div>
Outlets (Controller Communication)
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["form"]
open() {
this.element.classList.add("open")
// Call method on connected form controller
if (this.hasFormOutlet) {
this.formOutlet.reset()
}
}
close() {
this.element.classList.remove("open")
}
}
<div data-controller="modal"
data-modal-form-outlet="#task-form">
<div id="task-form" data-controller="form">
<!-- form content -->
</div>
</div>
Common Patterns
Infinite Scroll
<%# View %>
<div data-controller="infinite-scroll"
data-infinite-scroll-url-value="<%= tasks_path %>"
data-infinite-scroll-page-value="1">
<div id="tasks" data-infinite-scroll-target="container">
<%= render @tasks %>
</div>
<div data-infinite-scroll-target="loading" class="hidden">
Loading...
</div>
</div>
// app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "loading"]
static values = { url: String, page: Number }
connect() {
this.observer = new IntersectionObserver(
entries => this.handleIntersect(entries),
{ threshold: 0.1 }
)
this.observer.observe(this.loadingTarget)
}
handleIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadMore()
}
})
}
async loadMore() {
this.loadingTarget.classList.remove("hidden")
const response = await fetch(
`${this.urlValue}?page=${this.pageValue + 1}`,
{ headers: { "Accept": "text/vnd.turbo-stream.html" } }
)
if (response.ok) {
this.pageValue++
const html = await response.text()
Turbo.renderStreamMessage(html)
}
this.loadingTarget.classList.add("hidden")
}
disconnect() {
this.observer.disconnect()
}
}
Auto-Submit Form
<%= form_with url: search_path,
method: :get,
data: {
controller: "auto-submit",
turbo_frame: "results"
} do |f| %>
<%= f.text_field :q,
data: {
action: "input->auto-submit#submit",
auto_submit_target: "input"
} %>
<% end %>
<%= turbo_frame_tag "results" do %>
<%= render @results %>
<% end %>
// app/javascript/controllers/auto_submit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
submit() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.element.requestSubmit()
}, 300)
}
}
Flash Messages with Turbo
<%# app/views/layouts/_flash.html.erb %>
<div id="flash">
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>"
data-controller="flash"
data-flash-timeout-value="5000">
<%= message %>
<button data-action="click->flash#dismiss">×</button>
</div>
<% end %>
</div>
// app/javascript/controllers/flash_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { timeout: { type: Number, default: 5000 } }
connect() {
this.timer = setTimeout(() => this.dismiss(), this.timeoutValue)
}
dismiss() {
this.element.remove()
}
disconnect() {
clearTimeout(this.timer)
}
}
Turbo 8 Modern Features
Page Refresh (Turbo 8+)
Morphing - Update page without full reload, preserving scroll and focus:
<!-- Enable morphing globally -->
<meta name="turbo-refresh-method" content="morph">
<!-- Or per-page -->
<meta name="turbo-refresh-method" content="replace">
# Controller - trigger page refresh
class TasksController < ApplicationController
def update
@task.update(task_params)
# Send refresh signal to clients
respond_to do |format|
format.html { redirect_to tasks_path }
format.turbo_stream {
render turbo_stream: turbo_stream.action(:refresh)
}
end
end
end
Morph Refresh
Preserve elements during morph:
<!-- Element persists across morphs -->
<div id="video-player" data-turbo-permanent>
<video src="movie.mp4" controls></video>
</div>
<!-- Input state persists -->
<input type="text" data-turbo-permanent>
View Transitions API Integration
/* Smooth transitions during Turbo navigation */
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* Custom transition for specific elements */
.task-card {
view-transition-name: task-card;
}
Turbo Native (Mobile Apps)
Basic Setup
// iOS - SceneDelegate.swift
import Turbo
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var navigationController = UINavigationController()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
visit(url: URL(string: "https://example.com")!)
}
func visit(url: URL) {
let viewController = VisitableViewController(url: url)
navigationController.pushViewController(viewController, animated: true)
}
}
// Android - MainActivity.kt
import dev.hotwire.turbo.session.Session
import dev.hotwire.turbo.visit.TurboVisitOptions
class MainActivity : AppCompatActivity(), TurboActivity {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
TurboSessionNavHostFragment.visit(
url = "https://example.com",
options = TurboVisitOptions(action = TurboVisitAction.ADVANCE)
)
}
}
Native Bridge Patterns
<!-- app/views/tasks/show.html.erb -->
<% if turbo_native_app? %>
<%= link_to "Share", "#", data: {
turbo_frame: "_top",
controller: "bridge",
action: "click->bridge#share"
} %>
<% end %>
// app/javascript/controllers/bridge_controller.js
import { BridgeComponent } from "@hotwired/turbo-ios"
export default class extends BridgeComponent {
share() {
this.send("share", {
title: "Task Title",
url: window.location.href
})
}
}
Form Validation with Turbo
Client-Side Validation
<%= form_with model: @task,
data: {
controller: "form-validation",
action: "turbo:submit-end->form-validation#handleResponse"
} do |f| %>
<%= f.text_field :title,
required: true,
minlength: 5,
data: {
form_validation_target: "field",
action: "blur->form-validation#validateField"
} %>
<span data-form-validation-target="error" class="hidden text-red-500"></span>
<%= f.submit "Save",
data: { form_validation_target: "submit" } %>
<% end %>
// app/javascript/controllers/form_validation_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field", "error", "submit"]
validateField(event) {
const field = event.target
const error = field.parentElement.querySelector('[data-form-validation-target="error"]')
if (!field.validity.valid) {
error.textContent = field.validationMessage
error.classList.remove("hidden")
field.classList.add("border-red-500")
} else {
error.classList.add("hidden")
field.classList.remove("border-red-500")
}
}
handleResponse(event) {
const { success, fetchResponse } = event.detail
if (!success && fetchResponse.response.status === 422) {
// Server returned validation errors
this.disableSubmit(false)
}
}
disableSubmit(disabled) {
this.submitTarget.disabled = disabled
}
}
Server-Side Validation with Turbo
# app/controllers/tasks_controller.rb
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.turbo_stream {
render turbo_stream: [
turbo_stream.prepend("tasks", partial: "tasks/task", locals: { task: @task }),
turbo_stream.replace("task_form", partial: "tasks/form", locals: { task: Task.new })
]
}
else
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"task_form",
partial: "tasks/form",
locals: { task: @task }
), status: :unprocessable_entity
}
end
end
end
<!-- app/views/tasks/_form.html.erb -->
<%= turbo_frame_tag "task_form" do %>
<%= form_with model: task do |f| %>
<div class="field">
<%= f.label :title %>
<%= f.text_field :title, class: task.errors[:title].any? ? 'error' : '' %>
<% if task.errors[:title].any? %>
<span class="error-message"><%= task.errors[:title].first %></span>
<% end %>
</div>
<%= f.submit %>
<% end %>
<% end %>
Error Handling Patterns
Turbo Stream Error Responses
# app/controllers/concerns/turbo_streamable_errors.rb
module TurboStreamableErrors
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from StandardError, with: :handle_error
end
private
def handle_not_found(exception)
respond_to do |format|
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"flash",
partial: "shared/flash",
locals: { message: "Record not found", type: "error" }
), status: :not_found
}
format.html { redirect_to root_path, alert: "Record not found" }
end
end
def handle_error(exception)
Rails.logger.error(exception.message)
respond_to do |format|
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"flash",
partial: "shared/flash",
locals: { message: "An error occurred", type: "error" }
), status: :internal_server_error
}
format.html { redirect_to root_path, alert: "An error occurred" }
end
end
end
Handling Network Errors
// app/javascript/application.js
document.addEventListener("turbo:fetch-request-error", (event) => {
const { detail: { fetchResponse } } = event
if (!fetchResponse || fetchResponse.response.status >= 500) {
// Show offline/error UI
document.getElementById("error-banner").classList.remove("hidden")
}
})
document.addEventListener("turbo:frame-missing", (event) => {
// Handle missing frame gracefully
const frame = event.target
frame.innerHTML = `
<div class="alert alert-warning">
Content could not be loaded. <a href="${frame.src}">Try again</a>
</div>
`
event.preventDefault()
})
Progressive Enhancement
No-JS Fallbacks
<!-- Works without JavaScript -->
<%= form_with model: @task do |f| %>
<!-- Form works with or without Turbo -->
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
<!-- Link works without Turbo -->
<%= link_to "View", task_path(@task) %>
<!-- Progressive Turbo Frame -->
<turbo-frame id="comments" src="<%= task_comments_path(@task) %>">
<!-- Fallback content shown during load and without JS -->
<a href="<%= task_comments_path(@task) %>">View comments</a>
</turbo-frame>
Feature Detection
// app/javascript/controllers/progressive_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
// Check for required features
if ('IntersectionObserver' in window) {
this.enableLazyLoading()
}
if ('fetch' in window) {
this.enableAjaxFeatures()
}
}
enableLazyLoading() {
// Use IntersectionObserver for lazy loading
}
enableAjaxFeatures() {
// Enable AJAX-dependent features
}
}
Accessibility with Turbo/Stimulus
ARIA Live Regions
<!-- Announce dynamic updates to screen readers -->
<div id="tasks" aria-live="polite" aria-atomic="false">
<%= render @tasks %>
</div>
<div id="flash"
role="status"
aria-live="assertive"
aria-atomic="true">
<!-- Flash messages announced immediately -->
</div>
Keyboard Navigation
// app/javascript/controllers/keyboard_nav_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item"]
connect() {
this.currentIndex = 0
this.itemTargets[this.currentIndex]?.focus()
}
next(event) {
if (event.key === "ArrowDown") {
event.preventDefault()
this.currentIndex = Math.min(this.currentIndex + 1, this.itemTargets.length - 1)
this.itemTargets[this.currentIndex].focus()
}
}
previous(event) {
if (event.key === "ArrowUp") {
event.preventDefault()
this.currentIndex = Math.max(this.currentIndex - 1, 0)
this.itemTargets[this.currentIndex].focus()
}
}
select(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
event.target.click()
}
}
}
<div data-controller="keyboard-nav"
tabindex="0"
data-action="keydown->keyboard-nav#next keydown->keyboard-nav#previous">
<% @items.each do |item| %>
<div data-keyboard-nav-target="item"
tabindex="0"
role="button"
aria-label="<%= item.title %>"
data-action="keydown->keyboard-nav#select">
<%= item.title %>
</div>
<% end %>
</div>
Focus Management
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dialog", "closeButton"]
open() {
this.previousFocus = document.activeElement
this.dialogTarget.showModal()
this.closeButtonTarget.focus()
// Trap focus within modal
this.dialogTarget.addEventListener("keydown", this.trapFocus.bind(this))
}
close() {
this.dialogTarget.close()
this.previousFocus?.focus()
}
trapFocus(event) {
if (event.key === "Tab") {
const focusableElements = this.dialogTarget.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus()
event.preventDefault()
} else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus()
event.preventDefault()
}
}
}
}
Testing Turbo and Stimulus
System Tests for Turbo
# spec/system/tasks_spec.rb
require 'rails_helper'
RSpec.describe "Tasks", type: :system do
before do
driven_by(:selenium_chrome_headless)
end
it "creates a task with Turbo" do
visit tasks_path
within "#task_form" do
fill_in "Title", with: "New Task"
click_button "Create"
end
# Verify Turbo update without page reload
expect(page).to have_content("New Task")
expect(page).to have_current_path(tasks_path) # No redirect
expect(page).to have_selector("#task_form input[value='']") # Form reset
end
it "updates task via Turbo Stream" do
task = create(:task, title: "Old Title")
visit tasks_path
within "##{dom_id(task)}" do
click_link "Edit"
fill_in "Title", with: "New Title"
click_button "Update"
end
# Frame updated in place
within "##{dom_id(task)}" do
expect(page).to have_content("New Title")
expect(page).not_to have_field("Title")
end
end
it "handles validation errors with Turbo" do
visit tasks_path
within "#task_form" do
fill_in "Title", with: "" # Invalid
click_button "Create"
end
expect(page).to have_content("can't be blank")
expect(page).to have_selector("#task_form") # Form still visible
end
end
Testing Stimulus Controllers
// spec/javascript/controllers/search_controller.test.js
import { Application } from "@hotwired/stimulus"
import SearchController from "controllers/search_controller"
describe("SearchController", () => {
let application
let controller
beforeEach(() => {
document.body.innerHTML = `
<div data-controller="search">
<input data-search-target="input" type="text">
<div data-search-target="results"></div>
</div>
`
application = Application.start()
application.register("search", SearchController)
controller = application.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="search"]'),
"search"
)
})
afterEach(() => {
application.stop()
})
it("clears input and results", () => {
controller.inputTarget.value = "test query"
controller.resultsTarget.innerHTML = "<div>Results</div>"
controller.clear()
expect(controller.inputTarget.value).toBe("")
expect(controller.resultsTarget.innerHTML).toBe("")
})
it("searches when input changes", async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
text: () => Promise.resolve("<div>Search results</div>")
})
)
controller.inputTarget.value = "rails"
await controller.search()
expect(global.fetch).toHaveBeenCalledWith("/search?q=rails")
expect(controller.resultsTarget.innerHTML).toContain("Search results")
})
})
Debouncing and Throttling
Debounce Pattern
// app/javascript/controllers/debounced_search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
static targets = ["input", "results"]
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.performSearch()
}, this.delayValue)
}
async performSearch() {
const query = this.inputTarget.value
if (query.length < 2) return
const response = await fetch(`/search?q=${encodeURIComponent(query)}`)
const html = await response.text()
this.resultsTarget.innerHTML = html
}
disconnect() {
clearTimeout(this.timeout)
}
}
Throttle Pattern
// app/javascript/controllers/scroll_tracking_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { interval: { type: Number, default: 200 } }
connect() {
this.lastRun = 0
this.element.addEventListener("scroll", this.handleScroll.bind(this))
}
handleScroll() {
const now = Date.now()
if (now - this.lastRun >= this.intervalValue) {
this.track()
this.lastRun = now
}
}
track() {
const scrollPercentage = (this.element.scrollTop / this.element.scrollHeight) * 100
console.log(`Scrolled ${scrollPercentage}%`)
// Send analytics, etc.
}
}
Stimulus Components Integration
// Using stimulus-components library
import { Application } from "@hotwired/stimulus"
import Dropdown from "@stimulus-components/dropdown"
import Notification from "@stimulus-components/notification"
import Popover from "@stimulus-components/popover"
const application = Application.start()
application.register("dropdown", Dropdown)
application.register("notification", Notification)
application.register("popover", Popover)
<!-- Dropdown component -->
<div data-controller="dropdown">
<button data-action="dropdown#toggle">Menu</button>
<div data-dropdown-target="menu">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</div>
</div>
<!-- Notification component -->
<div data-controller="notification"
data-notification-delay-value="5000"
data-notification-remove-after-value="true">
<p>Your task was created successfully!</p>
<button data-action="notification#hide">×</button>
</div>
<!-- Popover component -->
<div data-controller="popover"
data-popover-translate-x="-50%"
data-popover-translate-y="8">
<button data-action="popover#toggle">Show Info</button>
<div data-popover-target="card" class="hidden">
Popover content
</div>
</div>
Debugging
Turbo Events
// Listen to Turbo events for debugging
document.addEventListener("turbo:before-fetch-request", (event) => {
console.log("Turbo request:", event.detail.url)
})
document.addEventListener("turbo:frame-missing", (event) => {
console.log("Frame missing:", event.target.id)
})
// Log all Turbo events
[
"turbo:click",
"turbo:before-visit",
"turbo:visit",
"turbo:before-fetch-request",
"turbo:before-fetch-response",
"turbo:submit-start",
"turbo:submit-end",
"turbo:before-stream-render",
"turbo:before-frame-render",
"turbo:frame-render",
"turbo:frame-load",
"turbo:load"
].forEach(event => {
document.addEventListener(event, (e) => console.log(event, e.detail))
})
Common Issues
- Frame not updating: Check frame IDs match between source and target
- Streams not working: Verify
turbo_stream_fromsubscription - Actions not firing: Check data-action syntax and controller registration
- Morphing issues: Use
data-turbo-permanentfor persistent elements - Focus loss: Implement focus management in Stimulus controllers
- Screen reader issues: Add proper ARIA attributes and live regions