Turbo Streams Rails: Broadcasting Nested Model Changes
I want reflect changes to a nested model, one on the other side of a has_many
relationship in a view looking at the parent model.
I imagine this is pretty common use case but I've looked this up and forgotten it at least three times now and none of the sources I hit immediately tell me, so I'm jotting it here.
Let's imagine I building a podcast player. I have many podcasts and each podcast has many episodes.
When I look at a podcast, I want to see information about the podcast itself and all the episodes it has available. If there are any changes to episode list (like new ones appearing), I want to broadcast that to anyone looking at the podcast page.
So I have simple active record models that look like this:
class Podcast < ApplicationRecord
has_many :episodes
end
app/models/podcast.rb
class Episode < ApplicationRecord
belongs_to :podcast
end
app/models/episode.rb
My view over an individual podcast might look like this (in Haml):
%h1
= image_tag @podcast.image
= @podcast.name
= simple_format @podcast.description
%ul
- @podcast.episodes.each do |episode|
%li= link_to episode.title, [@podcast, episode]
app/views/podcasts/show.html.haml
Now let's work to make the magic happen with turbo streams broadcasts.
The problem
If I were looking a view on the episode and wanting to stream updates to that model, it's really trivial and can use Rails convention over configuration to do that in a one-liner.
Here, I want to update the podcast view when one of its episodes changes. I want to render the podcast's view of the episode not the episodes view of itself. I'll be rendering a partial that lives in podcast's collection of views, breaking any convention over configuration trick I could use.
This is really easy, you just need to know which bits to tickle.
Refactor the views
Turbo needs a view partial to render when it updates or adds an item.
%li{id: dom_id(episode)}= link_to episode.title, [episode.podcast, episode]
app/views/podcasts/_episode.html.haml
The value for episode
will be passed into the view. I'm going to want to have each rendering to have a unique id and the rails helper dom_id
gives me that.
Because the podcast view just iterates through a collection when listing each episode, I'm going to use rails magic to call the collection.
%h1
= image_tag @podcast.image
= @podcast.name
= simple_format @podcast.description
%ul#podcast-episodes
= render partial: "episode", collection: @podcast.episodes
app/views/podcasts/show.html.haml
I've also give the enclosing ul
an id of podcast-episodes
. Good practice, but I'm going to need it later.
A reload later, it all works and nothing has changed. One more change is needed to the view.
= turbo_stream_from @podcast
%h1
= image_tag @podcast.image
= @podcast.name
= simple_format @podcast.description
%ul#podcast-episodes
= render partial: "episode", collection: @podcast.episodes
app/views/podcasts/show.html.haml
If I reload now, I'll something like this in my logs:
Turbo::StreamsChannel is transmitting the subscription confirmation
Turbo::StreamsChannel is streaming from Z2lkOi8vZ2xvcmlvdXMtcmFkaW8vRmVlZC8x
Cool – a web socket has been created and the browser is connected to it. Now we need send events down the wire when something changes the episode list.
Broadcasting from Active Record
I'm going to use, AR lifecycle hooks to do the broadcasting. Let's start with when an episode is created.
class Episode < ApplicationRecord
belongs_to :podcast
after_create_commit {
broadcast_prepend_later_to podcast,
partial: "podcasts/episode",
locals: { episode: self },
target: "podcast-episodes"
}
end
app/models/episode.rb
The after_create_commit
hook fires after the creation has been committed to the database. The broadcast_prepend_later_to
is the method that does the broadcasting. The later
part of the method name indicates that I want to send the broadcast asynchronously – rails will create a job to send the update out.
I am telling the broadcast method which partial to render, which object to pass into the partial (itself) and the id of the div to update. I've told it to broadcast to a channel based on the podcast parent of this object which is what our view is subscribed to.
The update and delete are similar.
class Episode < ApplicationRecord
belongs_to :podcast
after_create_commit {
broadcast_prepend_later_to podcast,
partial: "podcasts/episode",
locals: { episode: self },
target: "podcast-episodes"
}
after_update_commit {
broadcast_replace_later_to podcast,
partial: "podcasts/episode",
locals: { episode: self },
target: self
}
after_destroy_commit { broadcast_remove_to podcast, target: self }
end
app/models/episode.rb
Both update and delete need to change the bit of the view that represents this episode. We don't want to replace the entire div containing all episodes. This is why the target
is set to self
. Back up the view we used dom_id(episode)
, this lets Turbo understand how to connect the object to the DOM.
Delete is slightly different again: there is no later
equivalent to broadcast_remove_to
, it has to be send synchronously.
Member discussion