Hotwire: Turbo-Streaming ViewComponents

GitHub built the ViewComponent library to integrate with Ruby on Rails. It exists as a framework for creating reusable, testable, and encapsulated view components. GitHub’s long term goal is to build all their Rails-rendered HTML with ViewComponents.

Rails 7 ships with Hotwire - Turbo included - by default. Turbo Streams make up part of Turbo and deliver page changes as fragments of HTML “over the wire” (e.g. with web sockets). They allow you to bring your application to life and keep users up to date with changes, all achieved without writing any JavaScript whatsoever.

But Turbo Streams lean on Rails partials. ViewComponents are reusable, testable and more performant than partials. How can we integrate them with Turbo Streams so we can use them in place of traditional partials?

Let’s give it a try.

All the source code used to create this post is on GitHub. The rails new command with the latest version of Rails generated the code in this project.

Building a message board with ViewComponent

Let’s consider a basic messages page, built using ViewComponent. Each message displays the message body and the created at timestamp in a readable format. There is also a small piece of logic included that highlights the most recent messages in green.

# app/controllers/messages_controller.rb

def index
  @messages = Message.all
end
# app/views/messages/index.html.erb

<div>
  <h1 class="text-xl font-bold">Messages</h1>

  <div class="max-w-2xl">
    <%= render MessageComponent.with_collection(@messages) %>
  </div>
<div>

Like everything else in Ruby, ViewComponents are objects. That means we can keep our views clean and tidy by moving Ruby code and logic into our component file.

# app/components/message_component.rb

class MessageComponent < ViewComponent::Base
  include ActionView::RecordIdentifier

  def initialize(message:)
    @message = message
  end

  private

  attr_reader :message

  delegate :body, :created_at, to: :message, prefix: true

  def recent_message?
    message_created_at > 1.hour.ago
  end

  def timestamp
    message_created_at.strftime("%Y-%m-%d at %H:%M")
  end
end
# app/components/message_component/message_component.html.erb

<%= tag.div(id: dom_id(message), class: "message-component flex flex-wrap justify-between items-center py-3 border-b-2 border-gray-300") do %>
  <%= tag.div(
        message_body,
        class: class_names("mr-10", "text-green-600" => recent_message?, "text-gray-800" => !recent_message?)
      ) %>

  <%= tag.time(timestamp, class: "text-gray-400") %>
<% end %>

Integrating with Turbo Streams

Posting messages

A form on the messages page and a create action in the messages controller post new messages. With Turbo Streams we can render new messages in the response without re-rendering the whole page. Turbo tag helpers allow us to replace the form and append the new message to the list of messages.

--- a/app/views/messages/index.html.erb
+++ b/app/views/messages/index.html.erb

<div class="max-w-2xl">
  <h1 class="text-xl font-bold my-5">Messages</h1>

  <div id="messages" class="mb-5">
    <%= render MessageComponent.with_collection(@messages) %>
  </div>
+
+ <%= render "form" %>
</div>
# app/views/messages/_form.html.erb

<div class="my-3">
  <%= form_with url: messages_path, id: "new_message_form" do |f| %>
    <%= f.label :body, "Message:", class: "mr-2" %>
    <%= f.text_field :body, placeholder: "What do you want to say?", class: "my-2 mr-2" %>
    <%= f.submit "Post message", class: "p-2 cursor-pointer hover:bg-green-600" %>
  <% end %>
</div>
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb

class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end
+
+ def create
+   @message = Message.create(body: params[:body])
+
+   respond_to do |format|
+     format.html { redirect_to messages_path }
+     format.turbo_stream
+   end
+ end
end
# app/views/messages/create.turbo_stream.erb

<%= turbo_stream.replace "new_message_form", partial: "form" %>

<%= turbo_stream.append "messages" do %>
  <%= render MessageComponent.new(message: @message) %>
<% end %>

Broadcasting messages

What about when other users post messages?

The turbo-rails library has excellent integration for broadcasting ActiveRecord objects with Rails partials. Turbo::StreamsChannel allows HTML to be broadcast outside a model. We can use ApplicationController.render to render the ViewComponent as HTML that we can pass to Turbo. Now we have a class that we can use to broadcast messages from the messages controller.

# app/models/broadcast/message.rb

module Broadcast
  class Message
    def self.append(message:)
      new(message).append
    end

    def initialize(message)
      @message = message
    end

    def append
      Turbo::StreamsChannel.broadcast_append_later_to(
        :messages,
        target: "messages",
        html: rendered_component
      )
    end

    private

    attr_reader :message

    def rendered_component
      ApplicationController.render(
        MessageComponent.new(message: message),
        layout: false
      )
    end
  end
end
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb

def create
- @message = Message.create(body: params[:body])
+ @message = Message.new(body: params[:body])
+
+ if @message.save
+   Broadcast::Message.append(message: @message)
+ end

  respond_to do |format|
    format.html { redirect_to messages_path }
    format.turbo_stream
  end
end

Looking back

We built a basic message board that renders new messages without the need to refresh the page.

We leveraged ViewComponents to build a reusable message component.

We integrated with Hotwire and Turbo Streams to broadcast messages to users in real time.

And we did all this without writing any custom JavaScript!

Broadcasting messages using Turbo Streams