name: rails-37-style-frontend-hotwire description: Turbo Frames/Streams, morphing, drag & drop license: MIT
Hotwire Patterns
Turbo and Stimulus best practices from 37signals.
Turbo Morphing
- Enable globally:
turbo_refreshes_with method: :morph, scroll: :preserve - Listen for
turbo:morph-elementto restore client-side state - Use
data-turbo-permanentfor elements that shouldn't refresh - Ensure unique IDs - duplicates break morphing
- Set
refresh: :morphon frames withsrcto prevent removal during morphs (hotwired/turbo#1452)
Turbo Frames
- Wrap form sections in frames to prevent reset on partial updates
- Lazy-load expensive content via frames with
loading: "lazy" - Use
turbo_stream.replaceinstead of redirects for in-place updates - Use
refresh: :morphon lazy-loaded frames to prevent flicker - Use
data-turbo-frame="_parent"to target parent frame without knowing its ID (hotwired/turbo#1446)
Nested Frame Targeting
Target parent frames without hardcoding IDs:
<turbo-frame id="modal">
<turbo-frame id="search-results">
<!-- Component doesn't need to know parent's ID -->
<a href="/items/123" data-turbo-frame="_parent">
Select Item
</a>
</turbo-frame>
</turbo-frame>
Common Turbo Issues
| Problem | Solution |
|---|---|
| Timers not updating after morph | Bind to turbo:morph-element event |
| Forms resetting on page refresh | Wrap in turbo frames |
| Pagination breaking | Ensure unique IDs |
| Flickering on replace | Use method: :morph |
| localStorage state lost | Restore on turbo:morph-element |
Stimulus Best Practices
- Use Values API over
getAttribute()- cleaner, type-coerced - Use camelCase in JavaScript (even for data attributes)
- Always clean up in
disconnect()- timers, listeners - Use
:selfaction filter to scope events - Extract shared helpers to modules (
date_helpers.js,timing_helpers.js)
Timer Cleanup Pattern
Always clean up intervals and timeouts in disconnect():
export default class extends Controller {
#timer
connect() {
this.#timer = setInterval(() => this.refresh(), 30_000)
}
disconnect() {
clearInterval(this.#timer)
}
}
Timing Helpers
Extract common timing utilities to shared modules:
// helpers/timing_helpers.js
export function throttle(fn, delay = 1000) {
let timeoutId = null
return (...args) => {
if (!timeoutId) {
fn(...args)
timeoutId = setTimeout(() => timeoutId = null, delay)
}
}
}
export function debounce(fn, delay = 1000) {
let timeoutId = null
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), delay)
}
}
export function nextFrame() {
return new Promise(requestAnimationFrame)
}
export function nextEvent(element, eventName) {
return new Promise(resolve =>
element.addEventListener(eventName, resolve, { once: true })
)
}
State Persistence
- localStorage for UI preferences (expanded panels, draft content)
- Accept flash-of-collapsed-content as acceptable tradeoff
- Restore state on
turbo:morph-elementevents - Use
nextFrame()helper to wait for morph completion
Restoring localStorage on Morph
export default class extends Controller {
static targets = ["input"]
static values = { key: String }
initialize() {
this.save = debounce(this.save.bind(this), 300)
}
connect() {
this.restoreContent()
}
save() {
const content = this.inputTarget.value
if (content) {
localStorage.setItem(this.keyValue, content)
} else {
localStorage.removeItem(this.keyValue)
}
}
async restoreContent() {
await nextFrame()
const saved = localStorage.getItem(this.keyValue)
if (saved) {
this.inputTarget.value = saved
}
}
}
Wire it up to restore after morphs:
<%= form.text_area :body,
data: {
local_save_target: "input",
action: "input->local-save#save turbo:morph-element->local-save#restoreContent"
} %>
Links Over JavaScript
- Filter chips as plain
<a>tags, not JS-powered buttons - Better browser affordances (right-click, cmd+click)
- Simpler, more declarative code
- Let the browser do what browsers do
Morphing + Turbo Streams
When replacing content containing Turbo Frames:
render turbo_stream: turbo_stream.replace(
[@record, :container],
partial: "records/container",
method: :morph # Prevents flickering
)
Mark nested frames as permanent:
<%= turbo_frame_tag record, :details,
data: { turbo_permanent: true } %>
Element-Level Morph Events
Prefer element-specific events over global for better performance:
# In helper
def local_datetime_tag(datetime, style: :time, **attributes)
tag.time datetime: datetime.to_i,
data: {
local_time_target: style,
action: "turbo:morph-element->local-time#refreshTarget"
}
end
More efficient than turbo:morph@window because it only fires on the specific element.
Turbo Frames Preserve Form State
Wrap independent sections in frames:
<%= turbo_frame_tag @record, :settings do %>
<%= form_with model: @record do |form| %>
<!-- form fields -->
<% end %>
<% end %>
Respond with targeted replacement instead of redirect:
def update
@record.update(record_params)
render turbo_stream: turbo_stream.replace(
[@record, :settings],
partial: "records/settings"
)
end
POST + Turbo Streams for UI State
For state toggles (expand/collapse, watch/unwatch), use POST not GET:
<%= link_to toggle_path, data: { turbo_method: "post" } %>
Controller returns stream update instead of redirect.
Frame Morphing Configuration
Set refresh: :morph on frames with src:
<%= turbo_frame_tag "notifications",
src: notifications_path,
refresh: "morph" %>
Prevents frame removal during page morphs.
Broadcasts with Turbo Streams
Model-Level Broadcasts
Use broadcasts_refreshes for automatic updates:
module Card::Broadcastable
extend ActiveSupport::Concern
included do
broadcasts_refreshes
end
end
Subscribing to Broadcasts
<%= turbo_stream_from Current.user, :notifications %>
Auto-Submit Forms
Submit forms automatically on connect (useful for redirects/searches):
export default class extends Controller {
connect() {
this.element.addEventListener("turbo:submit-end",
this.#handleSubmitEnd.bind(this), { once: true })
this.submit()
}
submit() {
this.element.setAttribute("aria-busy", "true")
this.element.requestSubmit()
}
#handleSubmitEnd(event) {
if (event.detail.success) {
this.element.remove()
} else {
this.element.setAttribute("aria-busy", "false")
}
}
}
Auto-Save Forms
Save forms automatically after changes with debouncing:
const AUTOSAVE_INTERVAL = 3000
export default class extends Controller {
#timer
disconnect() {
this.submit()
}
async submit() {
if (this.#dirty) {
await this.#save()
}
}
change(event) {
if (event.target.form === this.element && !this.#dirty) {
this.#scheduleSave()
}
}
#scheduleSave() {
this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL)
}
async #save() {
clearTimeout(this.#timer)
this.#timer = null
this.element.requestSubmit()
}
get #dirty() {
return !!this.#timer
}
}
Lazy Loading on Visibility
Fetch content when element becomes visible:
export default class extends Controller {
static values = { url: String }
connect() {
const observer = new IntersectionObserver((entries) => {
if (entries.some(entry => entry.isIntersecting)) {
this.#fetch()
observer.disconnect()
}
})
observer.observe(this.element)
}
#fetch() {
get(this.urlValue, { responseKind: "turbo-stream" })
}
}
Dialog Controller Pattern
Handle dialogs with proper accessibility and lazy-loading:
export default class extends Controller {
static targets = ["dialog"]
static values = { modal: { type: Boolean, default: false } }
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
}
open() {
if (this.modalValue) {
this.dialogTarget.showModal()
} else {
this.dialogTarget.show()
}
this.loadLazyFrames()
this.dialogTarget.setAttribute("aria-hidden", "false")
}
close() {
this.dialogTarget.close()
this.dialogTarget.setAttribute("aria-hidden", "true")
}
closeOnClickOutside({ target }) {
if (!this.element.contains(target)) this.close()
}
// Prevent morphing from closing open dialogs
preventCloseOnMorphing(event) {
if (event.detail?.attributeName === "open") {
event.preventDefault()
}
}
loadLazyFrames() {
this.dialogTarget.querySelectorAll("turbo-frame").forEach(frame => {
frame.loading = "eager"
})
}
}
Copy to Clipboard
Simple clipboard pattern with success feedback:
export default class extends Controller {
static values = { content: String }
static classes = ["success"]
async copy(event) {
event.preventDefault()
this.element.classList.remove(this.successClass)
this.element.offsetWidth // Force reflow for animation reset
try {
await navigator.clipboard.writeText(this.contentValue)
this.element.classList.add(this.successClass)
} catch {}
}
}
Hotkey Controller
Handle keyboard shortcuts:
export default class extends Controller {
click(event) {
if (this.#isClickable && !this.#shouldIgnore(event)) {
event.preventDefault()
this.element.click()
}
}
#shouldIgnore(event) {
return event.defaultPrevented ||
event.target.closest("input, textarea, [contenteditable]")
}
get #isClickable() {
return getComputedStyle(this.element).pointerEvents !== "none"
}
}
Usage:
<button data-controller="hotkey"
data-action="keydown.n@document->hotkey#click">
New Item <kbd>N</kbd>
</button>
Stimulus for Cached Fragment Personalization
Cached partials can't access Current.user. Move user-specific styling to client-side:
// initializers/current.js
class Current {
get user() {
const id = document.head.querySelector('meta[name="current-user-id"]')?.content
return id ? { id: parseInt(id) } : null
}
}
window.Current = new Current()
// controllers/personalize_controller.js
export default class extends Controller {
static targets = ["item"]
static classes = ["mine"]
itemTargetConnected(element) {
if (element.dataset.creatorId == Current.user?.id) {
element.classList.add(this.mineClass)
}
}
}
<!-- In layout -->
<meta name="current-user-id" content="<%= Current.user&.id %>">
<!-- Cached partial uses data attributes, not conditionals -->
<div data-creator-id="<%= comment.creator_id %>"
data-personalize-target="item">
Frame Reload on Document Morph
Reload frames after document-level morphs:
export default class extends Controller {
reload() {
this.element.reload()
}
morphReload(event) {
const newElement = event.detail.newElement
if (newElement?.tagName === "TURBO-FRAME") {
event.preventDefault()
this.element.reload()
}
}
}
<%= turbo_frame_tag "dynamic-content",
src: content_path,
data: {
controller: "frame",
action: "turbo:morph@document->frame#reload"
} %>
Navigable List Pattern
Keyboard-navigable lists with arrow key support:
export default class extends Controller {
static targets = ["item"]
static values = {
selectionAttribute: { type: String, default: "aria-selected" },
actionableItems: { type: Boolean, default: false }
}
connect() {
this.selectFirst()
}
navigate(event) {
switch (event.key) {
case "ArrowDown": this.#selectNext(); break
case "ArrowUp": this.#selectPrevious(); break
case "Enter": this.#activateCurrent(event); break
}
}
selectFirst() {
this.#selectItem(this.#visibleItems[0])
}
#selectItem(item) {
if (!item) return
this.#clearSelection()
item.setAttribute(this.selectionAttributeValue, "true")
item.scrollIntoView({ block: "nearest" })
this.currentItem = item
}
#clearSelection() {
this.itemTargets.forEach(item =>
item.removeAttribute(this.selectionAttributeValue))
}
get #visibleItems() {
return this.itemTargets.filter(item => !item.hidden)
}
#selectNext() {
const index = this.#visibleItems.indexOf(this.currentItem)
if (index < this.#visibleItems.length - 1) {
this.#selectItem(this.#visibleItems[index + 1])
}
}
#selectPrevious() {
const index = this.#visibleItems.indexOf(this.currentItem)
if (index > 0) {
this.#selectItem(this.#visibleItems[index - 1])
}
}
#activateCurrent(event) {
if (this.actionableItemsValue && this.currentItem) {
const clickable = this.currentItem.querySelector("a,button")
clickable?.click()
event.preventDefault()
}
}
}
Turbo Permanent Elements
Use data-turbo-permanent to preserve elements across navigations:
<!-- Footer frames that persist across page loads -->
<div id="footer_frames" data-turbo-permanent>
<%= render "notifications/tray" %>
<%= render "quick_actions/bar" %>
</div>
<!-- Rich text editor content during morphs -->
<div class="editor-content" data-turbo-permanent>
<%= form.rich_text_area :body %>
</div>
Testing Turbo Frames
Use the built-in assertion helpers (hotwired/turbo-rails#742):
# Assert frame exists with specific attributes
assert_turbo_frame "comments", loading: "lazy"
assert_turbo_frame @user, :profile, target: "_top"
# Assert frame contains specific content
assert_turbo_frame "search-results" do
assert_select "li", count: 5
end
# Assert frame doesn't exist
assert_no_turbo_frame "admin-panel"
Turbo Flash Helper
Create a helper for flash messages in Turbo Stream responses:
module TurboFlash
extend ActiveSupport::Concern
included do
helper_method :turbo_stream_flash
end
private
def turbo_stream_flash(**flash_options)
turbo_stream.replace(:flash,
partial: "layouts/shared/flash",
locals: { flash: flash_options })
end
end
Usage in controller:
def create
@record = Record.create!(record_params)
render turbo_stream: [
turbo_stream.prepend("records", @record),
turbo_stream_flash(notice: "Created successfully")
]
end
Drag and Drop Patterns
Simple Drag Controller
For basic D&D between containers, use a focused controller instead of heavyweight sortable libraries:
export default class extends Controller {
static targets = ["item", "container"]
static values = { url: String }
static classes = ["draggedItem", "hoverContainer"]
async dragStart(event) {
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.dropEffect = "move"
await nextFrame() // Wait for drag to start
this.dragItem = event.target.closest("[data-drag-target='item']")
this.sourceContainer = this.dragItem.closest("[data-drag-target='container']")
this.dragItem.classList.add(this.draggedItemClass)
}
dragOver(event) {
event.preventDefault()
const container = event.target.closest("[data-drag-target='container']")
this.#clearContainerHoverClasses()
if (container && container !== this.sourceContainer) {
container.classList.add(this.hoverContainerClass)
}
}
async drop(event) {
const container = event.target.closest("[data-drag-target='container']")
if (!container || container === this.sourceContainer) return
this.wasDropped = true
// POST to server, let it re-render the column
await post(this.urlValue, {
body: JSON.stringify({
item_id: this.dragItem.dataset.id,
target: container.dataset.column
})
})
}
dragEnd() {
this.dragItem.classList.remove(this.draggedItemClass)
this.#clearContainerHoverClasses()
if (this.wasDropped) this.dragItem.remove()
this.sourceContainer = null
this.dragItem = null
this.wasDropped = false
}
#clearContainerHoverClasses() {
this.containerTargets.forEach(c =>
c.classList.remove(this.hoverContainerClass))
}
}
Key insights:
- Use
await nextFrame()before applying drag classes (prevents visual glitches) - Track source container to prevent dropping on self
- Optimistically remove on successful drop
- Let the server handle ordering logic and re-render
Drag Visual Feedback
.drag--dragged-item {
filter: grayscale(1) brightness(0.97);
opacity: 0.6;
outline: 2px dashed var(--color-accent);
}
.drag--hover-container {
background-color: var(--color-drop-zone);
outline: 2px dashed var(--color-accent);
transition: background-color 200ms;
}
/* Disable hover states during drag to prevent flicker */
ul:not(.dragging) li:hover {
background-color: var(--hover-color);
}
Conditional Draggable Items
Make draggability a render-time decision:
<%= render partial: "items/item",
collection: @items,
locals: { draggable: @allow_reorder } %>
<%# In the partial %>
<article draggable="<%= local_assigns.fetch(:draggable, false) %>"
data-drag-target="item"
data-id="<%= item.id %>">
Accessibility for Drag Handles
<button class="drag-handle">
<%= image_tag "drag.svg", aria: { hidden: true } %>
<span class="visually-hidden">
Drag to reorder
</span>
</button>
Using @rails/request.js with Turbo
Make @rails/request.js use Turbo's fetch for proper integration:
// application.js
window.fetch = Turbo.fetch
Progressive Installation
Show interactive UI only after JavaScript loads:
connect() {
this.element.classList.add("installed")
}
.interactive-widget {
visibility: hidden;
}
.interactive-widget.installed {
visibility: visible;
}
Also restore after morphs:
data-action="turbo:morph@document->widget#install"