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 / 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.
DHH@dhh@skalifowitz The solution to all your web problems is a majestically, monolithic Ruby on Rails application that rejects the SPA and embraces HTML over the wire. Also, relational databases are good and MySQL is fine. Season with Redis and ElasticSearch and you have all your ever need 😄14:27 PM - 31 Jan 2020
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)
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.
Hey Mitchell, you're absolutely right!
I fixed the post with your comment. Thanks