Hotwire
Hotwire Support
Juntos includes full support for Hotwire—the Rails-native approach to building interactive applications with minimal JavaScript.
Table of Contents
- Hotwire Support
Overview
Hotwire consists of two main components:
- Turbo — Fast navigation and real-time updates without writing JavaScript
- Stimulus — Lightweight JavaScript controllers for progressive enhancement
Juntos implements both, allowing you to write idiomatic Rails code that works across all targets.
Turbo Streams Broadcasting
Model Broadcasting
Add real-time updates to your models using familiar Rails callbacks:
# app/models/message.rb
class Message < ApplicationRecord
validates :body, presence: true
# Broadcast new messages to all subscribers
after_create_commit do
broadcast_append_to "chat_room",
target: "messages",
partial: "messages/message",
locals: { message: self }
end
# Broadcast removal when deleted
after_destroy_commit do
broadcast_remove_to "chat_room",
target: "message_#{id}"
end
end
Available broadcasting methods:
| Method | Action |
|---|---|
broadcast_append_to |
Append to target element |
broadcast_prepend_to |
Prepend to target element |
broadcast_replace_to |
Replace target element |
broadcast_remove_to |
Remove target element |
broadcast_update_to |
Update target content |
broadcast_before_to |
Insert before target |
broadcast_after_to |
Insert after target |
broadcast_json_to |
Send JSON event (for React/JS components) |
View Subscription
Subscribe to broadcasts in your views:
<%# app/views/messages/index.html.erb %>
<div class="chat-room">
<%# Subscribe to real-time updates %>
<%= turbo_stream_from "chat_room" %>
<%# Messages container - broadcasts append here %>
<div id="messages">
<%= render @messages %>
</div>
<%= form_with model: Message.new do |f| %>
<%= f.text_field :body %>
<%= f.submit "Send" %>
<% end %>
</div>
The turbo_stream_from helper establishes the real-time connection. The element format varies by target:
- Server targets (Node/Bun/Deno):
<turbo-cable-stream-source>for Action Cable - Cloudflare:
<turbo-stream-source>for simple WebSocket protocol - Browser: Subscribes via BroadcastChannel API (no element needed)
JSON Broadcasting for React Components
Turbo Streams broadcast HTML fragments, which works great for server-rendered views but conflicts with React’s DOM management. Use broadcast_json_to to send JSON events that React components can use to update their state:
# app/models/node.rb
class Node < ApplicationRecord
belongs_to :workflow
after_create_commit do
broadcast_json_to "workflow_#{workflow_id}", "node_created"
end
after_update_commit do
broadcast_json_to "workflow_#{workflow_id}", "node_updated"
end
after_destroy_commit do
broadcast_json_to "workflow_#{workflow_id}", "node_destroyed"
end
end
This broadcasts JSON events:
{
"type": "node_created",
"model": "Node",
"id": 42,
"data": {"id": 42, "label": "New Node", "position_x": 100, "position_y": 200, ...}
}
Subscribing with JsonStreamProvider:
Use the JsonStreamProvider React context to handle subscriptions. It automatically uses WebSocket on server targets and BroadcastChannel on the browser target:
# app/views/workflows/Show.jsx.rb
import JsonStreamProvider from '../../../lib/JsonStreamProvider.js'
import WorkflowCanvas from 'components/WorkflowCanvas'
export default
def Show(workflow:)
%x{
<JsonStreamProvider stream={`workflow_${workflow.id}`}>
<WorkflowCanvas ... />
</JsonStreamProvider>
}
end
React component using the context:
# app/components/WorkflowCanvas.jsx.rb
import [useJsonStream], from: '../../lib/JsonStreamProvider.js'
export default
def WorkflowCanvas(...)
stream = useJsonStream()
useEffect(-> {
return unless stream.lastMessage
payload = stream.lastMessage
case payload.type
when 'node_created'
setNodes(->(prev) { [*prev, to_flow_node(payload.data)] })
when 'node_updated'
setNodes(->(prev) { prev.map { |n| n.id == payload.id.to_s ? to_flow_node(payload.data) : n } })
when 'node_destroyed'
setNodes(->(prev) { prev.filter { |n| n.id != payload.id.to_s } })
end
}, [stream.lastMessage])
end
The provider handles the transport automatically:
- Browser target: Uses
BroadcastChannelfor same-origin tab sync - Server targets (Node/Bun/Deno): Uses WebSocket with Action Cable protocol
- Cloudflare: Uses WebSocket with simple protocol (hibernation-friendly)
See the Workflow Builder demo for a complete example.
Turbo Frames
Wrap content in frames for partial page updates:
<%# Clicking "Edit" loads just the form, not the whole page %>
<%= turbo_frame_tag dom_id(@article) do %>
<h1><%= @article.title %></h1>
<%= link_to "Edit", edit_article_path(@article) %>
<% end %>
<%# edit.html.erb - the frame replaces in-place %>
<%= turbo_frame_tag dom_id(@article) do %>
<%= form_with model: @article do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>
<% end %>
Controller Format Negotiation
Handle both HTML and Turbo Stream responses:
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@message = Message.new(message_params)
if @message.save
respond_to do |format|
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to messages_path }
end
end
end
end
Juntos generates Accept header checks:
// Generated JavaScript
if (context.request.headers.accept?.includes('text/vnd.turbo-stream.html')) {
// Render turbo_stream response
} else {
// Render HTML response
}
Stimulus Controllers
Write Stimulus controllers in Ruby:
Try it — edit the Ruby code to see how it transpiles:
class ChatController < Stimulus::Controller
def connect()
scroll_to_bottom()
end
def scroll_to_bottom()
element.scrollTop = element.scrollHeight
end
def send_message(event)
event.preventDefault()
# Handle form submission
end
end
Rails Integration
In Rails development, the Stimulus middleware serves Ruby controllers as JavaScript on-the-fly:
- Create
app/javascript/controllers/chat_controller.rb - The middleware intercepts requests for
chat_controller.js - Transpiles the Ruby source and returns JavaScript
- Generates
controllers/index.jsmanifest automatically
No build step required during development.
Juntos Build
For Juntos targets, the builder:
- Transpiles all
.rbcontrollers to.js - Copies any existing
.jscontrollers - Generates
controllers/index.jsmanifest
WebSocket Support by Target
| Target | Turbo Package | Transport | Protocol |
|---|---|---|---|
| Browser | @hotwired/turbo |
BroadcastChannel |
Local tabs only |
| Node.js | @hotwired/turbo-rails |
ws package |
Action Cable |
| Bun | @hotwired/turbo-rails |
Native WebSocket | Action Cable |
| Deno | @hotwired/turbo-rails |
Native WebSocket | Action Cable |
| Cloudflare | @hotwired/turbo |
Durable Objects | Simple (hibernation-friendly) |
| Vercel | @hotwired/turbo-rails |
Stubbed | Platform limitation |
WebSocket Endpoint
All server targets expose a /cable endpoint for WebSocket connections. The protocol varies:
Action Cable protocol (Node/Bun/Deno):
// Subscribe using Action Cable format
ws.send(JSON.stringify({
command: 'subscribe',
identifier: JSON.stringify({
channel: 'Turbo::StreamsChannel',
signed_stream_name: btoa(JSON.stringify('chat_room'))
})
}));
Simple protocol (Cloudflare):
// Subscribe using simple format
ws.send(JSON.stringify({
type: 'subscribe',
stream: 'chat_room'
}));
// Receive broadcasts as { stream, html }
ws.onmessage = (event) => {
const { stream, html } = JSON.parse(event.data);
Turbo.renderStreamMessage(html);
};
The simple protocol allows Cloudflare’s Durable Object to truly hibernate between broadcasts, while Action Cable’s ping requirements keep server processes active.
Cloudflare Durable Objects
On Cloudflare, real-time features use Durable Objects with a simple protocol designed for hibernation:
// wrangler.toml (auto-generated)
[[durable_objects.bindings]]
name = "TURBO_BROADCASTER"
class_name = "TurboBroadcaster"
Why a simple protocol?
Action Cable requires server-initiated pings every 3 seconds to keep connections alive. On Durable Objects, this would prevent hibernation and incur continuous billing. Instead, Cloudflare uses:
- Base Turbo (
@hotwired/turbo) instead of turbo-rails - Simple WebSocket protocol:
{ type: 'subscribe', stream }/{ stream, html } - No server pings: Cloudflare’s edge maintains connections while the DO hibernates
- True hibernation: The Durable Object only wakes when broadcasts occur
The TurboBroadcaster class handles:
- WebSocket upgrade requests at
/cable - Stream subscriptions with hibernation-aware storage
- Broadcasting to subscribers (wakes DO briefly, then hibernates)
- Automatic cleanup on disconnect
RPC and Real-Time Features
Juntos provides two complementary mechanisms for browser-server communication:
| Feature | RPC Transport | Broadcasting |
|---|---|---|
| Direction | Browser → Server (request/response) | Server → Browser (push) |
| Use case | Model operations, data fetching | Real-time updates |
| Endpoint | /__rpc |
/cable (WebSocket) |
| Data flow | Synchronous call, wait for result | Async event subscription |
How They Work Together
A typical workflow:
- User action → Path helper makes RPC call (e.g.,
notes_path.post(...)) - Server processes → Controller creates record, triggers
after_create_commit - Broadcast → Turbo Stream or JSON event sent to all subscribers
- UI updates → All connected clients receive the update
# Model broadcasts on create
class Note < ApplicationRecord
after_create_commit { broadcast_append_to "notes" }
end
# View fetches via path helper, subscribes to broadcasts
notes_path.get().then { |r| r.json.then { |data| setNotes(data) } }
turbo_stream_from "notes" # Receives broadcasts from other users
This pattern enables:
- Optimistic updates: UI updates immediately via local state
- Consistency: Broadcasts sync all clients to server state
- Offline support: Browser target works without server, syncs when reconnected
Demo
See the Chat Demo for Turbo Streams broadcasting and Stimulus controllers, or the Notes Demo for path helper RPC with JSON broadcasting.
Limitations
Vercel Edge
Vercel Edge Functions don’t support persistent WebSocket connections. Broadcasting methods are stubbed (no-op). Consider using external pub/sub services (Pusher, Ably) for real-time features on Vercel.
Browser Target
The browser target uses BroadcastChannel for same-origin communication between tabs. This doesn’t provide cross-device real-time updates—it’s for local development and offline-first apps where each browser instance has its own database.
For true real-time across devices, deploy to a server target (Node.js, Bun, Deno, Cloudflare).