Home Using ViewComponents with Turbo
Post
Cancel

Using ViewComponents with Turbo

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 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 of turbo_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 with turbo_frame. Using tag.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.