DEV Community

Bruno Valentino
Bruno Valentino

Posted on • Updated on

Using ViewComponents with ActionCable

I've had a lot of fun lately using ViewComponents (known as ActionView::Component but will be renamed soon).

It is a framework, from the GitHub team, that allows you to build "components" that can be rendered. They sit in the /app/components directory, and each component has a class and a view.

GitHub logo github / view_component

View components for Rails

To create a component, you can use the included generator. For example, let's create a component for an Item that has a title and a url. Our component will simply require an item.

$ bin/rails g component Item item

These files will be created:

  • app/components/item_component.rb
  • app/components/item_component.html.erb
  • test/components/item_component_test.rb

Unlike using partials and decorators (concerns, helpers, etc), components can be tested, and must be since we can reuse the same component in different views (and even with ActionCable as we'll see later).

This is how our item_component_test.rb looks like:

require "test_helper"

class ItemComponentTest < ActionView::Component::TestCase
  test "renders the item title" do
    item = Item.new(title: 'Sample item', url: 'https://dev.to/')

    assert_match(
      item.title,
      render_inline(ItemComponent.new(item: item)).to_html
    )
  end
end

We're simply expecting to find the title of the Item in the rendered component.

We can now define the component, which receives an Item in its constructor.

class ItemComponent < ActionView::Component::Base
  validates :item, presence: true

  def initialize(item:)
    @item = item
  end
  attr_reader :item
end

Note how we can validate the presence of an Item.

Our view, for sake of simplicity, will just render the item's title and URL as a link wrapped in a div with an id.

<div id="item-<%= item.id %>">
  <%= link_to item.title, item.url, target: '_blank' %>
</div>

Our component is a class, meaning we can take advantage of creating the methods we need. In this case, we can move the id generation to a public method and, that way, we can later access it from other places.

With this new method, the ItemComponent looks like this now:

class ItemComponent < ActionView::Component::Base
  validates :item, presence: true

  def initialize(item:)
    @item = item
  end
  attr_reader :item

  def dom_id
    "item--#{item.id}"
  end
end

And we updated the view:

<div id="<%= dom_id %>">
  <%= link_to item.title, item.url, target: '_blank' %>
</div>

In the test, we should also check for that id is present in the dom_id method to ensure uniqueness.

require "test_helper"

class ItemComponentTest < ActionView::Component::TestCase
  test "renders the item title" do
    item = Item.new(title: 'Sample item', url: 'https://dev.to/')

    assert_match(
      item.title,
      render_inline(ItemComponent.new(item: item)).to_html
    )
  end

  test "#dom_id" do
    item = Item.create(title: 'Sample item', url: 'https://dev.to/')

    assert_match(
      item.id,
      ItemComponent.new(item: item).dom_id
    )
  end
end

That's it! The component is ready and all tests pass.

We can now render this component in our views:

<% @items.each do |item| %>
  <%= render ItemComponent.new item: @item %>
<% end %>

Integrating with ActionCable

ViewComponents are a great companion to ActionCable. As DHH said, we can ditch SPA and simply send HTML over the wire, and doing that is straightforward with ViewComponents.

In our example, the Item belongs to a Board. When a user updates an item, we want to show the updates to other users seeing the same board.

We've already setup ActionCable and we have a BoardChannel that users connect to. We can broadcast the item update to this channel from the ItemsController:

class ItemsController < ApplicationController
  before_action :set_board

  def update
    if @item.update(item_params)
      item_component = ItemComponent.new(item: @item)
      BoardChannel.broadcast_to(
        @board, event: 'item_updated', 
        payload: {
          id: @board.id,
          item_id: @item.id, 
          dom_id: item_component.dom_id, 
          html: view_context.render(item_component) 
        }
      )

      redirect_to @board
    else
      render :edit
    end
  end

  # ...
end

As you can see, the ItemComponent is rendered and sent part of the payload of the broadcast. We're also sending the dom_id, which we can use in javascript to find the DOM element and update its HTML with the one provided.

Here's our StimulusJS controller, with the connection to the BoardChannel and a function to process the events received:

import { Controller } from 'stimulus'
import consumer from '../channels/consumer';

export default class extends Controller {
  static targets = ['items']

  connect() {
    const that = this;

    consumer.subscriptions.create({
      channel: 'BoardChannel',
      id: that.context.element.dataset.id
    }, { received(data) {
      that.receivedEvent(data);
    } })
  }

  receivedEvent(data) {
    switch (data.event) {
      case 'item_updated':
        const element = document.getElementById(data.payload.dom_id);
        if (element) {
          element.outerHTML = data.payload.html;
        }
        break;
    }
  }
}

Our views will now behave in real-time!

In the future, when we make changes to the ItemComponent, we won't have to worry about updating the javascript or any ActionCable logic. It will simply work.

Final words

I've been using ViewComponents in all personal projects and, recently, at work.

The encapsulation, testability, and performance it provides are simply amazing. I look forward to the moment ViewComponents is made part of Rails core.

Top comments (2)

Collapse
 
helphop profile image
Mitchell Gould

Thanks for this article I'm just learning about ViewComponents. In your ItemsController update method you wrote:

ItemChannel.broadcast_to

Was that supposed to be BoardChannel.broadcast_to

I'm a little confused.

Collapse
 
bvalentino profile image
Bruno Valentino

Hey Mitchell, you're absolutely right!
I fixed the post with your comment. Thanks