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

Turbo stream actions:

  • 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:

Turbo Broadcast listener

  • 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:

Turbo Broadcast CRUD

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 access inbox 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 to inboxes
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 to dom_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