#17 Turbo Streams: Broadcasts
Turbo Streams in Controller VS Broadcasts: When to use which?
Rule of thumb:
- -> If you want to send updates to a page when a user INTERACTS with the page (clicks something) -> HTTP Turbo Streams
- -> If you want to send updates to a page WITHOUT user interaction -> Websocket Turbo Stream Broadcasts
I would use broadcasts for:
- live chat
- “async” notification
- live dashboards
I would NOT use broadcasts for:
- user-triggered CRUD updates (like a post, add a comment, edit a record, search)
If you look at the docs for turbo broadcasts, the suggested way to trigger them are ActiveRecordCallbacks in a model.
Callbacks to use:
after_create_commit
after_update_commit
-
after_destroy_commit
(btw,delete
doesn’t fire a callback.destroy
does) after_save_commit
-
append
# add on bottom of DOM ID (<div id="abc">
) -
prepend
# add on top of DOM ID -
replace
# replace a DOM ID (example: with an element with another id) -
update
# update content INSIDE a DOM ID -
remove
# no template required for this one! -
before
# add before DOM ID (not inside it)! -
after
# add after DOM ID (not inside it)!
1. Broadcast Create/Update/Destroy #
- Initial setup:
rails g scaffold inbox name
rails db:migrate
bundle add faker
- Add a
turbo_stream_from
target with an ID anywhere on a page.
# app/views/inboxes/index.html.erb
++<%= turbo_stream_from "inbox_list" %>
<div id="inboxes">
<%= render @inboxes %>
</div>
- This will “listen” to broadcasts, with a target
inbox_list
- Now, when you navigate to a page that has
turbo_stream_from
, you will see something like this in the console:
- Next, add a broadcasts, with a target
inbox_list
in the model:
# app/models/inbox.rb
class Inbox < ApplicationRecord
validates :name, presence: true, uniqueness: true
++broadcasts_to ->(inbox) { :inbox_list }
end
The above
- requires
<%= turbo_stream_from :inbox_list %>
- a default target -
<div id="inboxes">
-
a default partial -
"inboxes/_inbox"
-
This will let you broadcast all activity (create, update, destroy).
- That’s it! Now, you can try to create/update/destroy records in the console or in another tab:
Inbox.create(name: Faker::Quote.famous_last_words)
Inbox.first.update(name: "Edited at #{Time.zone.now}")
Inbox.first.destroy
- … and changes will be “broadcasted” without page refresh:
2. broadcasts_to
is too magical. Let’s unbuild it! #
-
broadcasts_to ->(inbox) { :inbox_list }
translates to:
# app/models/inbox.rb
--broadcasts_to ->(inbox) { :inbox_list }
++
++broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
- or, more precisely
# app/models/inbox.rb
class Inbox < ApplicationRecord
--broadcasts_to ->(inbox) { :inbox_list }
--
--broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
++
++after_create_commit { broadcast_append_to "inbox_list" }
++after_update_commit { broadcast_replace_to "inbox_list" }
++after_destroy_commit { broadcast_remove_to "inbox_list" }
++
++# after_create_commit { broadcast_prepend_to "inbox_list" } # would add on top
++# after_update_commit { broadcast_update_to "inbox_list" } # would add dom_id(inbox) inside dom_id(inbox)
end
- or, even more precisely:
# app/models/inbox.rb
class Inbox < ApplicationRecord
--broadcasts_to ->(inbox) { :inbox_list }
--
--broadcasts_to ->(inbox) { :inbox_list }, inserts_by: :append, target: 'inboxes'
--
--after_create_commit { broadcast_append_to "inbox_list" }
--after_update_commit { broadcast_replace_to "inbox_list" }
--after_destroy_commit { broadcast_remove_to "inbox_list" }
--
--# after_create_commit { broadcast_prepend_to "inbox_list" } # would add on top
--# after_update_commit { broadcast_update_to "inbox_list" } # would add dom_id(inbox) inside dom_id(inbox)
++
++after_create_commit do
++ broadcast_append_to('inbox_list', target: 'inboxes', partial: "inboxes/inbox", locals: { inbox: self })
++end
++
++after_update_commit do
++ broadcast_replace_to('inbox_list', target: self, partial: "inboxes/inbox", locals: { inbox: self })
++end
++
++after_destroy_commit do
++ broadcast_remove_to('inbox_list', target: self)
++end
end
So, in a turbo_stream you can specify:
- a
turbo_stream_from
broadcast (connection ID) to listen to - a target - HTML element with an ID
DOM ID
(<div id="abc">
) that gets replaced/updated/appended/destroyed… with a partial or HTML - a partial/html to stream… for which you can set locals
- locals - local variables
I recommend to use explicity paths. No shortcut magic!
3. Broadcast HTML
: Update inboxes count on create/destroy. #
- add a
div id
in the view that will be updated by the broadcast. - add a
turbo_stream_from
. You can use the same stream from above.
# app/views/inboxes/index.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inbox_count">
<%= @inboxes.count %>
</div>
- send some HTML to the target
div id
when an inbox is created/destroyed - set a matching turbo stream ID in the view and controller (
inbox_list
)
# app/models/inbox.rb
after_commit :send_html_counter, on: [ :create, :destroy ]
def send_html_counter
broadcast_update_to('inbox_list', target: 'inbox_count', html: "There are #{Inbox.count} inboxes")
# broadcast_update_to('inbox_list', target: 'inbox_count', html: Inbox.count)
end
Now, you can create/destroy a record in the rails console
and the counter will be updated!
4. Broadcast Partial
: Update inboxes count on create/destroy. #
- create the partial:
# app/views/inboxes/_inbox_count.html.erb
Total inboxes:
<%= inbox_q %>
- display it in a view:
# app/views/inboxes/index.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inbox_count">
<%= render partial: "inboxes/inbox_count", locals: {inbox_q: Inbox.count} %>
</div>
- add a broadcast to update the content within
<div id="inbox_count">
# app/models/inbox.rb
after_commit :send_partial_counter, on: [ :create, :destroy ]
def send_partial_counter
broadcast_update_to('inbox_list', target: 'inbox_count', partial: "inboxes/inbox_count", locals: { inbox_q: Inbox.count })
end
Surely, you can also send a partial without locals ;)
5. Error broadcasting button_to
#
Before rails 7.0.0rc
you might have a CSFR error when streaming button_to
:
`ensure_session_is_enabled!': Request forgery protection requires a working session store but your application has sessions disabled. You need to either disable request forgery protection, or configure a working session store. (ActionController::RequestForgeryProtection::DisabledSessionError)
For example, in a case like this:
# app/views/inboxes/_inbox.html.erb
<div id="<%= dom_id inbox %>">
<%= inbox.id %>
<%= inbox.name %>
++<%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</div>
It can be fixed by adding:
# config/application.rb
++ config.action_controller.silence_disabled_session_errors = true
6. Broadcasting ViewComponent #
Previously I wrote about (4 ways to Turbo Stream ViewComponent). They won’t work from a model.
However, there is a way:
- Install ViewComponent
- Create a component
bundle add view_component
rails g component inbox inbox
# app/components/inbox_component.html.erb
<div id="<%= dom_id inbox %>">
<%= inbox.name %>
<%= link_to "Show this inbox", inbox %>
<%= link_to "Edit this inbox", edit_inbox_path(inbox) %>
<%= button_to "Destroy this inbox", inbox_path(inbox), method: :delete %>
</div>
- add
attr_reader :inbox
to be able to accessinbox
without@inbox
# app/components/inbox_component.rb
class InboxComponent < ViewComponent::Base
attr_reader :inbox
def initialize(inbox:)
@inbox = inbox
end
end
- now you can render a single inbox or a collection like this:
<%= render(InboxComponent.with_collection(@inboxes)) %>
<%= render InboxComponent.new(inbox: Inbox.first) %>
- so, render the component(s) in the view:
# app/views/inboxes/_inbox.html.erb
<%= turbo_stream_from "inbox_list" %>
<div id="inboxes">
<%= render(InboxComponent.with_collection(@inboxes)) %>
<%#= render @inboxes %>
</div>
- and broadcast them in the model LIKE THIS
# app/models/inbox.rb
after_create_commit do
# these will not render the HTML
# InboxComponent.new(inbox: self)
# render_to_string(InboxComponent.new(inbox: self))
# view_context.render(InboxComponent.new(inbox: self))
# InboxComponent.new(inbox: self).render_in(view_context)
# this will:
broadcast_append_to('inbox_list', target: 'inboxes', html: ApplicationController.render(InboxComponent.new(inbox: self)))
end
7. Broadcasting associations #
- Add
messages
toinboxes
rails g scaffold message body:text inbox:references
# app/models/inbox.rb
has_many :messages
# app/models/message.rb
belongs_to :inbox
- render messsages inside an inbox
- add a
turbo_stream_from
target that is UNIQUE for this inbox
# app/views/inboxes/show.html.erb
<%= render @inbox %>
<%= turbo_stream_from @inbox, :messages %>
<div id="<%= dom_id(@inbox, :messages) %>">
<%= render @inbox.messages %>
</div>
- Now, broadcast messages into an inbox
-
[inbox, :messages]
will stream todom_id(@inbox, :messages)
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :inbox
# lets you use dom_id in a model
include ActionView::RecordIdentifier
after_create_commit do
broadcast_prepend_to [inbox, :messages], target: dom_id(inbox, :messages), partial: "messages/message", locals: { message: self }
# broadcast_prepend_to [inbox, :messages], target: ActionView::RecordIdentifier.dom_id(inbox, :messages)
end
after_update_commit do
broadcast_update_to [inbox, :messages], target: self, partial: "messages/message", locals: { message: self }
end
after_destroy_commit do
broadcast_remove_to [inbox, :messages], target: self
end
end
- Now you can add messages to an inbox and they will be broadcasted into the inbox!
Inbox.first.messages.create body: SecureRandom.hex
Inbox.first.messages.last.update body: "hello world"
Inbox.first.messages.last.destroy
8. Best practices when broadcasting #
It is never recommended to use callbacks in a model.
I highly recommend to trigger broadcasts in controller actions instead.
This way, your code will be more predictable and reliable.
# app/controllers/messages_controller.rb
def destroy
Turbo::StreamsChannel.broadcast_update_to([inbox, :messages],
target: @message,
partial: "messages/message",
locals: { message: @message })
Turbo::StreamsChannel.broadcast_update_to('global_notifications',
target: 'flash',
partial: "shared/flash",
locals: { flash: flash })
end
P.S. WTF dom_id
?! #
Here’s how ActionView::RecordIdentifier dom_id
works:
# dom_id(Inbox.first)
# => inbox_1
# dom_id(Inbox.first, :hello)
# => hello_inbox_1
That’s it!
Official Turbo/Broadcastable docs
Next, I hope to explore Broadcasts + Devise + Authorization
Did you like this article? Did it save you some time?