Hotwire components that refresh themselves
This is a guest collaboration with Jesper Christiansen, a long-time fan of most things Ruby on Rails.
Earlier this year, I made a quick tweet about a pattern that I think makes working with Hotwire apps much better.
When you need to make a bit of UI that runs some code in the background, you can use turbo_streams
to ‘refresh’ the front-end when the work starts, is in-progress, and finally when it’s finished.
But the problem is that, out of the box, turbo_streams
use partials. And frankly, it just kind of sucks to work with. I found myself annoying by having to match up dom_id
s across files and passing in data as locals. And it’s hard to search the codebase for partials, leading to cases where you change a partial and break something elsewhere just because it wasn’t easy to find usage.
Over time, this has lead me toward a pattern that I hadn’t seen elsewhere:
- Make a component for the view (and associated logic/helpers)
- When you do something on the backend, use
turbo_stream.replace
and take advantage of therenderable
option to pass the component object instead of a partial + locals - Do the same thing later when broadcasting from a job
- Let the component encapsulate implementation details like the
dom_ids
andActionCable
broadcast channels
So let’s dive into the details!
Hotwire: Components should refresh themselves
Let’s say you have a user card component that show some basic information about the user, as well as if we have sent them an introduction email or not.
We want the card to have a button that will send the user an introduction email when clicked - with in-progress state shown in the component, until we’re done sending.
We would like to keep the user card updated in the places where it’s shown, and we do that using Hotwire’s refresh functionality.
A tangled mess of identifiers
You’ll typically perform those refreshes with something like this in a background job (or model):
Turbo::StreamsChannel.broadcast_replace_to(
"my-unique-identifier",
target: id,
partial: "user/card"
locals: { user: @user }
)
This isn’t bad at all! But.. What is this "my-unique-identifier"
string we refer to? That’s of course the id
of the element we need replaced.
So inside our partial we have something like
<% tag.div id: "my-unique-identifier" do %>
...
<% end %>
Of course in practice we’d use the dom_id
method from ActionView::RecordIdentifier
- so we can do dom_id(@user, :user_card)
.
We’ll go ahead and change the places that refresh our user_card
partial to use ActionView::RecordIdentifier.dom_id(@user, :user_card)
instead of the previously hardcoded my-unique-identifier-string
.
Not too bad, but less than ideal! In this simple walkthrough of how to implement this functionality, we’ve now already run into the issue; that when the id
of the component changes, we need to go through our code-base and update all the places that referenced this magic string. Fingers crossed that our tests are in place and we didn’t miss anything! 😅
In a bigger app, there might be many places where we need to perform updates like these.
A way of cleaning this up could be to introduce a new method in a user_helper.rb
or similar, something like:
def user_card_id(user)
dom_id(user, :user_card)
end
Then we could use said helper method in all the places we need it. It’s better! But it feels decoupled from the core logic of the user_card
partial.
If in the future we need to get rid of the partial, we’ll have to remember to clean this up or it’ll be a piece of history that isn’t used anymore. Sigh.
And we haven’t even talked about turbo_stream_from [@user, :user_card_refresh]
which is another thing that needs to be in sync in places where it’s needed - and it might even be conditional on some state.
View Components to the rescue
I find something like ViewComponent (..or Phlex, or whatever gets you excited) great for use-cases like these. We can build components with a greater level of complexity and have everything packaged neatly into a single Ruby class.
This means that component isn’t just responsible for what it renders, but it can also become responsible for refreshing it’s own content.
Are we mixing responsibilities and breaking the sacred Single Responsibility Principle? Probably, but I find that I much prefer working in systems that value locality of behavior these days.
Let’s take a look at our simple UI::UserCard
.
(Side note: I love using UI
module for components because it’s super clear this object is a view component and it lets you drop the “Component” suffix that people tend to use for every…single…component in their apps. Highly recommend!)
class UI::UserCard < ApplicationComponent
def initialize(user:)
@user = user
end
def id
dom_id(@user, :user_card)
end
def broadcast_channel
[@user, :user_card_refresh]
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(
broadcast_channel,
target: id,
renderable: self,
layout: false
)
end
end
We can then use our id
and broadcast_channel
methods in our component’s view:
<% tag.div id: id do %>
<%= helpers.turbo_stream_from broadcast_channel %>
<div class="text-lg font-bold"><%= @user.name %></div>
<div class="text-sm text-slate-500"><%= @user.email %></div>
<% end %>
We’re now referencing the id
from our component, and streaming from our broadcast_channel
.
This simplifies things quite a bit in places where we’re performing updates which causes us to want to re-render the UI::UserCard
for a given user.
We can now make use of the broadcast_refresh!
method on the component itself. The following code can be called from a background job, the model or wherever you’re making an update you want to broadcast to other users.
UI::UserCard.new(user: @user).broadcast_refresh!
The component knows how to refresh itself and all of the “magic” identifiers we relied on are kept in one central place within the component itself.
That way we can use it in our controller when we send a new introduction email to a user:
def create
@user = Current.account.users.find(params[:id])
@user.send_introduction_email_later!
user_card = UI::UserCard.new(user: @user, sending_email: true)
render turbo_stream: turbo_stream.replace(user_card.id, user_card)
end
This ends up enqueuing a background job via the send_introduction_email_later!
method on the user and it responds with a turbo_stream to replace the UI::UserCard
for the user that clicked the button that triggered this action.
Notice how we’re making use of the same id
method on the user component as we use in the refresh example. So if we ever change this in the future, we need to update it in one place.
Do we need to keep the stream open for eternity?
Keeping the stream open for forever might not be ideal - especially since we only really want to update the sending state for a user, and nothing else.
Let’s introduce a sending_email?
state to our UI::UserCard
to show that we’re sending an email. We can also use this state to only stream from the channel when we’re in the middle of sending an email.
Let’s update our component’s initialize with a sending_email?
method
class UI::UserCard < ApplicationComponent
def initialize(user:, sending_email: false)
@user = user
@sending_email = sending_email
end
def sending_email?
@sending_email
end
def introduction_email_sent?
@user.introduction_email_sent_at.present?
end
...
end
In our component’s view, we’ll add a conditional for this new sending_email
state
<% tag.div id: id do %>
<div class="text-lg font-bold"><%= @user.name %></div>
<div class="text-sm text-slate-500"><%= @user.email %></div>
<% if sending_email? %>
<%= helpers.turbo_stream_from broadcast_channel %>
<%= render UI::Spinner.new(size: :sm, message: "Sending introduction email") %>
<% elsif !introduction_email_sent? %>
<%= helpers.button_to "Send introduction email", user_emails_introduction_path(@user) %>
<% end %>
<% end %>
Then in our background job that sends the introduction email:
class SendUserIntroductionEmailJob < ApplicationJob
queue_as :default
def perform(user)
user.send_introduction_email_later!
UI::UserCard.new(user: user, sending_email: false).broadcast_refresh!
end
end
We now replace the UI::UserCard
with a version that has sending_email
set to true
, which means we’ll subscribe to updates from the broadcast_channel
and showing an in-progress state in the component. When that work finishes, we replace the component with the sending_email: false
equivalent, which doesn’t listen to updates anymore.
Conclusion
Wrapping parts of the UI in a component containing business logic using something like ViewComponent can be a huge benefit.
It keeps the logic encapsulated, and introduces a clear way to do things like refreshing the UI when something changes.
It also makes refactoring easier in the future, since we have one place where we have the component’s id
and one place where we defined the channel to push updates to (broadcast_channel
).
Often times, keeping behavior together can make it much easier to work in a codebase than following a strict adherence to separation of concerns.
Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.