This article is part of my effort to write about some of the real-world development challenges I encountered during the development of my podshare.fm application. In this article, I’ll show how I used a ViewComponent to represent the status of a background job while updating the view using TurboFrames.
If you haven’t had a chance to check out ViewComponents, I’d highly recommend you take a look. ViewComponents are a great way to encapsulate view-related logic, making it much easier to test and reuse.
The Import Status Component
Within the application, a background job is responsible for fetching information about a podcast episode, then adding it to the user’s feed. While this is in process, a ViewComponent is used to display to the user how many episodes are currently importing. If there are no episodes importing, then it simply renders nothing.
Here’s what it looks like:
Import Status Component Example
Here’s a simplified version of the ImportStatusComponent
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ImportStatusComponent < ViewComponent::Base
attr_reader :feed
def initialize(feed:)
@feed = feed
super
end
def render?
importing_count > 0
end
def importing_message
"You have #{importing_count} #{'episode'.pluralize(importing_count)} currently importing"
end
private
def importing_count
@importing_count ||= feed.episode_share_requests.count(&:importing?)
end
end
and its corresponding simplified view:
1
2
3
4
5
<%= tag.turbo_frame id: "feed:#{feed.external_id}:import_status" do %>
<div class="alert alert-secondary" role="alert">
<%= importing_message %>
</div>
<% end %>
As you can see in the view, the output is wrapped in a TurboFrame with the goal of updating the view when the background job completes. However, you may have noticed that
tag.turbo_frame
is used instead ofturbo_frame
render the markup for the TurboFrame. At the time of writing this article, there is a known issue with ViewComponent that causes some odd rendering issues withturbo_frame
. Usingtag.turbo_frame
is the current work-around.
Rendering the View
The initial render of the component is done on the show
action of the feed page as would normally be expected:
1
2
3
4
5
# app/views/feeds/show.html.erb
# ...
<%= render ImportStatusComponent.new(feed: @feed) %>
<%= turbo_stream_from @feed %>
# ...
We also add a call to turbo_stream_from
, passing the specific @feed
object, creating a subscription to a named stream we can reference later.
When the background job eventually completes, it updates an EpisodeShareRequest
ActiveRecord and marks the status as complete
. When this happens, the ImportStatusComponent
needs to be “re-rendered”, effectively removing the message from the page.
In a traditional JavaScript implementation, this might be accomplished by polling a separate endpoint that returns the current status of the import job. Instead, with TurboStreams, we simply need to broadcast the replace_to
message to update the view.
Updating the View with TurboStreams
If we were updating the view within an inline controller turbo action or TurboStream template, we could use the turbo_stream
tag builder to render the view for the response. As of version 1.4.0 of the turbo_rails
gem it’s also much easier to do so, with a ViewComponent. For example:
1
turbo_stream.replace "feed:#{feed.external_id}:import_status", ImportStatusComponent.new(feed:)
In that example, the tag builder has access to the view context so it’s able to pass that context into the ViewComponent and instruct it to render.
However, in this application, the view needs to be updated from a model within an ActiveRecord after_update_commit callback. Therefore we don’t have access to the normal view context, so the ViewComponent needs to be rendered first, then the resulting markup passed directly to the Turbo::StreamsChannel
.
So, within our ActiveRecord model:
1
2
3
4
5
6
7
8
9
10
class EpisodeShareRequest < ApplicationRecord
after_update_commit :broadcast_feed_updates
# ...
def broadcast_feed_updates
rendered_content = ApplicationController.render(ImportStatusComponent.new(feed:),
layout: false)
broadcast_replace_to feed, html: rendered_content,
target: "feed:#{feed.external_id}:import_status"
end
end
Currently, the broadcast_replace_to
method requires that a key of html
, partial
or template
be passed with a value representing the content to render. To generate the markup, the render
method is first called on ApplicationController
passing an instance of the ImportStatusComponent
along with the layout: false
option. This returns the rendered content. The rendered content is then passed to broadcast_replace_to
via the html
option along with the same target
name as the TurboFrame id
.
This is also where the named stream we created in the /feeds/show.html.erb
is important. The first argument to broadcast_replace_to
needs to match the object used when initially creating the stream. This ensures the broadcast messages
What I really like about this implementation is it was all done without directly writing any JavaScript. It’s also well encapsulated for logic reuse, making it easily testable.