DEV Community

loading...

Making Hotwire play nice with ViewComponent

Kudakwashe Paradzayi
Extreme Programmer 👨‍💻 × Fullstack Javascript Developer 💪🏽 × Hackerman 😎
Originally published at blog.kuda.dev on ・4 min read

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.

freshstore-gif.gif

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

and in the html templates you can then define

<%= turbo_frame_tag dom_id(@line_item) do %>
   <!-- line item html logic -->
<% end %>

Enter fullscreen mode Exit fullscreen mode

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

        ...
    }

Enter fullscreen mode Exit fullscreen mode

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 🥂

Discussion (0)