Have you ever wondered if you could use hotwire with view_component
instead of partials. If you have, maybe tried and failed, welcome here.
By default hotwire is made to work with partials, which is the built in way to extract 'components' in the context of rails based apps. In a certain application I was building I have used view_component
(inspired by react) in place of partials because it is a framework for building reusable, testable & encapsulated view components in Ruby on Rails.
It was all fun and games until I wanted to make hotwire to work with the view components I had made.
We will use an e-commerce application as an example to demonstrate the problem at hand.
NB: no JavaScript was used in making this interactive, except for displaying the loading spinners
Hotwire uses websockets to broadcast changes to all clients listening, and this is very fast considering websockets maintain a persistent open connection with the client and the changes feel almost instantaneous. In this example when a user adds a product to cart, hotwire will broadcast the new product component and it will be replaced on the frontend, smooth as an SPA, with minimum javascript.
When a product is added to cart, we want to show it change on the UI, unlike a traditional API request which returns JSON, hotwire returns html, in the form of turbo streams. When the response arrives it will only change the specific section in the page that has a turbo frame that matches a specific id.
Using view components in this context would also help reduce the response's payload to only contain that section that has to be replaced. However view components and turbo streams dont play nice together so I googled around for a quick solution and found this answer on the hotwire forum. I then used that answer to successfully render a ViewComponent in the response.
# app/controller/carts_controller.rb
class CartsController < ApplicationController
def update
product = Product.find(cart_params[:product_id])
line_item = CartUpdateService.call(@product, cart_params[:quantity])
product_component = ProductComponent.new(
product: product,
quantity_in_cart: @line_item.quantity
)
respond_to do |format|
format.turbo_stream {
stream = turbo_stream.replace product do
view_context.render(product_component)
end
render turbo_stream: stream
head :ok
}
end
end
This was a good solution until I realised that you can only render a stream once, so this crumbled when I wanted to broadcast changes to other areas of the application, like adding line_items
to the cart in the UI, updating cart count, etc. This is because, by design turbo broadcasts are meant to be initiated in the model, which I sort of thought as a code smell because not only was the model responsible for data persistence, validations and business logic, it was also responsible for the way the UI worked. We all strive for skinny models, don't we.
This was how it was before, when using partials
# app/models/line_item.rb
class LineItem < ApplicationRecord
...
after_create_commit :broadcast_prepend_line_item, if: :current_user_present?
private def broadcast_prepend_line_item
broadcast_prepend_to "#{Current.user.id}:line_items", partial: 'line_items/line_item', locals: { line_item: self, store: self.order.store }
end
...
end
And as far as I had researched there was no way to use View Component instead of partials to broadcast these updates.
So I went and dug into the hotwire source code to look for clues whether it was possible or not.
After series of iterations I came up with the concept of LiveComponent
, a class that would inherit from ViewComponent::Base
but also include hotwire modules to allow broadcasts and streams. Here is the complete code
# app/components/live_component.rb
class LiveComponent < ViewComponent::Base
include Turbo::FramesHelper, Turbo::Streams::StreamName, Turbo::Streams::Broadcasts
attr_reader :streamable, :target
def initialize(view_context: nil, **args)
@view_context = view_context
end
def broadcast_replace
return unless @view_context.present?
broadcast_replace_later_to(
streamable,
target: target,
content: @view_context.render(self)
)
end
def broadcast_prepend
return unless @view_context.present?
broadcast_prepend_to(
streamable,
target: broadcast_target_default,
content: @view_context.render(self)
)
end
def broadcast_remove
return unless @view_context.present?
broadcast_remove_to(
streamable,
target: target
)
end
private def broadcast_target_default
target.class.model_name.plural
end
end
And this is exactly a ViewComponent with some live extras baked in. Continuing with out illustration of broadcasting changes to the line_items in the cart, we would implement it as a live component like
# app/components/live_line_item_component.rb
class LiveLineItemComponent < LiveComponent
def initialize(view_context: nil, line_item:, current_user:)
@line_item = line_item
# these will be used by LiveComponent to identify
# the stream channel and the targeted frame-tag on the UI
@streamable = "#{current_user.id}:line_items"
@target = @line_item
super
end
def render?
@line_item.quantity > 0
end
end
and in the html templates you can then define
<%= turbo_frame_tag dom_id(@line_item) do %>
<!-- line item html logic -->
<% end %>
Now in cart_controller.rb
when you add to cart you can then use the live component
respond_to do |format|
format.turbo_stream {
live_line_item_component = LiveLineItemComponent.new(view_context: view_context, line_item: @line_item, current_user: Current.user)
if @line_item.destroyed?
live_line_item_component.broadcast_remove
elsif @line_item.quantity == 1 && @line_item.updated_at == @line_item.created_at
live_line_item_component.broadcast_prepend
else
live_line_item_component.broadcast_replace
end
...
}
I know the code is a little bit 'dirty' and I'm sure some of you smart people will find ways to improve on it.
It was a fun little exploration and I am happy it worked out.
Cheers π₯
Top comments (0)