Turbo Stream Broadcast Patterns
Encapsulate broadcast logic in model concerns and call broadcasts explicitly from controllers. Use composite stream names for targeting specific audiences.
Why
- Explicit control: Broadcasts happen when you intend, not automatically
- Testability: Broadcasts can be tested in isolation
- Flexibility: Different contexts may need different broadcast behavior
- Performance: No unexpected broadcasts on every save
Pattern 1: Broadcast Concerns in Models
Encapsulate broadcast logic in a model concern:
# app/models/message/broadcasts.rb
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages,
target: [room, :messages]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_update
broadcast_replace_to room, :messages,
target: [self, :presentation],
partial: "messages/presentation",
attributes: { maintain_scroll: true }
end
def broadcast_remove
broadcast_remove_to room, :messages
end
end
# app/models/message.rb
class Message < ApplicationRecord
include Broadcasts
endPattern 2: Call Broadcasts from Controllers
Explicitly call broadcasts in controller actions:
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@message = @room.messages.create!(message_params)
@message.broadcast_create
end
def update
@message.update!(message_params)
@message.broadcast_update
end
def destroy
@message.destroy
@message.broadcast_remove
end
endPattern 3: Composite Stream Names
Use arrays for stream names to create hierarchical channels:
<%# Subscribe to room-specific messages %>
<%= turbo_stream_from @room, :messages %>
<%# Subscribe to global room list %>
<%= turbo_stream_from :rooms %>
<%# Subscribe to user-specific room updates %>
<%= turbo_stream_from Current.user, :rooms %>
<%# Subscribe to card activity %>
<%= turbo_stream_from @card, :activity %>This generates stream names like:
"Z2lkOi8vYXBwL1Jvb20vMQ:messages"(room + messages)"rooms"(global)"Z2lkOi8vYXBwL1VzZXIvMQ:rooms"(user + rooms)
Pattern 4: Targeted Broadcasts by Audience
Different users may need different broadcasts:
# app/controllers/rooms/opens_controller.rb
class Rooms::OpensController < RoomsController
def create
room = Rooms::Open.create!(room_params)
broadcast_create_room(room)
end
private
# Open rooms: broadcast to everyone
def broadcast_create_room(room)
broadcast_prepend_to :rooms,
target: :shared_rooms,
partial: "sidebars/room",
locals: { room: room }
end
end
# app/controllers/rooms/closeds_controller.rb
class Rooms::ClosedsController < RoomsController
private
# Closed rooms: broadcast only to members
def broadcast_create_room(room)
html = render_to_string(partial: "sidebars/room", locals: { room: room })
room.users.each do |user|
broadcast_prepend_to user, :rooms,
target: :shared_rooms,
html: html # Render once, broadcast to many
end
end
endPattern 5: Controller Broadcast Helpers
Include Turbo broadcast modules in ApplicationController:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Turbo::Streams::Broadcasts
include Turbo::Streams::StreamName
endThen broadcast directly from controller actions:
class RoomsController < ApplicationController
def destroy
@room.destroy
broadcast_remove_to :rooms, target: [@room, :list]
end
endPattern 6: Broadcast with morph
Use method: :morph for smart DOM updates:
def broadcast_card_update
broadcast_replace_to @board,
target: [@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: self }
endPattern 7: Custom Attributes
Pass custom attributes for client-side handling:
def broadcast_update
broadcast_replace_to room, :messages,
target: [self, :presentation],
partial: "messages/presentation",
attributes: { maintain_scroll: true } # Custom attribute
endHandle in JavaScript:
// app/javascript/controllers/maintain_scroll_controller.js
beforeStreamRender(event) {
if (event.detail.newStream.hasAttribute("maintain_scroll")) {
// Preserve scroll position
}
}Pattern 8: Conditional Broadcasting
Broadcast based on context:
module Card::Broadcastable
def broadcast_changes
return unless published?
broadcast_replace_to board,
target: [self, :card_container],
partial: "cards/container",
method: :morph
end
endTurbo Stream Template Organization
app/views/
├── cards/
│ ├── closures/
│ │ ├── create.turbo_stream.erb
│ │ └── destroy.turbo_stream.erb
│ ├── comments/
│ │ ├── create.turbo_stream.erb
│ │ └── update.turbo_stream.erb
│ └── update.turbo_stream.erbExample template:
<%# app/views/cards/closures/create.turbo_stream.erb %>
<%= turbo_stream.replace(
[@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: @card.reload }
) %>
<% if @source_column %>
<%= turbo_stream.replace(
dom_id(@source_column),
partial: "columns/column",
method: :morph,
locals: { column: @source_column }
) %>
<% end %>When NOT to Use Callbacks for Broadcasts
# Bad: Broadcasts on every save, even in background jobs
after_save_commit :broadcast_changes
# Good: Explicit broadcast when needed
# Called from controller:
@card.update!(card_params)
@card.broadcast_changesRules
- Encapsulate broadcast logic in model concerns
- Call broadcasts explicitly from controllers
- Use composite stream names (
[room, :messages]) for scoping - Render once, broadcast to many for multi-user broadcasts
- Use
method: :morphfor smart DOM updates - Don't use callbacks for broadcasts (be explicit)
- Custom attributes can signal client-side behavior