3 min read

Turbo Streams Rails: Broadcasting Nested Model Changes

Turbo Streams broadcasts when there are changes to the nested model
Turbo Streams Rails: Broadcasting Nested Model Changes
Photo by Matt Botsford / Unsplash

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.