25. 7. 2023

More expressive APIs with View Components

View components offer two primary ways to interact with the component: passing arguments to the initializer and using slots:

render SomeComponent.new(some_params) do |component|
  component.with_some_slot(some_other_params) { "My slot content" }
end

Having worked with Phlex (the better way to build views in Ruby), I came to appreciate calling methods directly on the component. While the slot API offers a lot and is versatile, it can sometimes feel like you have to work around it rather than with it.

Let’s consider a BreadCrumbsComponent as an example. Traditionally, one might pass in all the data using an array of hashes, like this:

BreadCrumbsComponent.new([
  {label: "Home", url: root_path},
  {label: "Settings", url: settings_path},
  {label: "Notifications", url: settings_notifications_path}
])

However, I prefer a more expressive API like this:

BreadCrumbsComponent.new do |bread|
  bread.crumb "Home", root_path
  bread.crumb "Settings", settings_path
  bread.crumb "Notifications", settings_notifications_path
end

Solution with lambda slots

To achieve the desired API using the ViewComponent gem, we can utilize lambda slots:

class BreadCrumbsComponent < ViewComponent::Base
  renders_many :crumbs, -> (label, url) { @paths << {label: label, url: url} }
end

With a lambda slot, you can create a similar API, though it forces you to use specific method naming, as you will need to call c.with_crumb("Home", root_path) in the template.

BreadCrumbsComponent.new do |c| 
  c.with_crumb "Home", root_path
end

However, there is an issue here. When iterating through the @paths supposedly set by the slot calls, you will notice that it doesn’t work as expected. The with_crumb method did not have any effect. Why? ViewComponent only calls the block if you make a call to the accessor method for those slots, in this case crumbs, which is what you do when in your template you are calling crumbs.each …. This means that even if you don’t render the slots, you still need to call the accessor method for it, a hint that we are somewhat abusing slots in this scenario.

Better solution

My preferred approach would be to directly call an instance method on the component, like this:

class BreadCrumbsComponent < ViewComponent::Base
  def crumb(label, url)
    @paths << {label: label, url: url}
  end
end

Now this should work seamlessly:

BreadCrumbsComponent.new do |bread|
  bread.crumb "Home", root_path
end

However the same caveat applied. The block is not called.

To address this issue, I explored how I could call the block myself. The block is stored in @__vc_render_in_block, a very private looking instance variable.

Then I realized that a call to content would invoke the block. The solution is to add a before_render lifecycle method. This calls the block and like this the crumb method calls on the component will be performed as expected. So our component ruby file can look like this:

class BreadcrumbsComponent < ViewComponent::Base
  attr_reader :paths

  def before_render
    content # ensures that block is called
  end

  def initialize(&block)
    @paths = []
  end

  def crumb(label, url)
    @paths << {label: label, url: url}
  end
end

For further use, it would be a good idea to extract this into a module like ViewComponent::CallBlock (or a better named module), and to then simply include it.

While some may argue that passing this data directly might be a cleaner approach since it is essentially data, I wanted to explore if I could achieve this particular API with a view component.

In conclusion: With a small tweak you can, directly calling instance methods on the component to shape your component. This way you can offer a very expressive and straightforward API.

This post was written entirely by Roland Studer, but revised with the help of chat gpt.