DEV Community

loading...
Cover image for Advanced ViewComponent patterns in Rails

Advanced ViewComponent patterns in Rails

Abeid Ahmed
Self-taught programmer. Javascript and ruby enthusiast. Life long learner!
Updated on ・6 min read

ViewComponents are useful if you have tons of reusable partials with a significant amount of embedded Ruby. ViewComponent lets you isolate your UI so that you can unit test them and more.

By isolation, I mean that you cannot share your instance variables without explicitly passing them to the component. For example, in a normal Rails partials you can do this.

<%=# posts/show.html.erb %>

<h1><%= @post.name %></h1>
<%= render "some_partial" %>


<%=# posts/_some_partial.html.erb %>

<p><%= @post.created_at %></p>
Enter fullscreen mode Exit fullscreen mode

Notice, how the instance variables are shared without explicitly passing it.

In this article, I'll be going over some patterns that I've learned by reading through other people's codebase.

Getting started

If you haven't already, let's get started by installing the gem itself.

# Gemfile

gem "view_component", require: "view_component/engine"
Enter fullscreen mode Exit fullscreen mode

After you've installed the gem, create a new file at app/components/application_component.rb.

# app/components/application_component.rb

class ApplicationComponent < ViewComponent::Base
end
Enter fullscreen mode Exit fullscreen mode

We'll use this class to add reusable code so that other components can inherit from it, and ViewComponent generators will also automatically inherit from this class if you've declared it.

Advanced patterns

Building GitHub's subhead component

To warm-up, we'll be building a simple subhead component that GitHub utilizes heavily in their settings page.

Subhead component

rails g component subhead
Enter fullscreen mode Exit fullscreen mode

First, we'll start with the not-so-good approach. Then we'll optimize it to fit any purpose.

Upon closely looking at the subhead component, we can notice that

  • It has a title (mandatory)
  • It can have a description (optional)
  • It may have other states (such as danger)
# app/components/subhead_component.rb

class SubheadComponent < ApplicationComponent
  def initialize(title:, description: nil, danger: false)
    @title = title
    @description = description
    @danger = danger
  end

  def render?
    @title.present?
  end
end
Enter fullscreen mode Exit fullscreen mode
<%=# app/components/subhead_component.html.erb %>

<div>
  <h2><%= @title %></h2>

  <p class="<%= @danger ? 'subhead--danger' : 'some other class' %>">
    <%= @description %>
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode

And then, you can use this component in your .erb files, by calling,

<%= render SubheadComponent.new(title: "something", description: "subhead description")
Enter fullscreen mode Exit fullscreen mode

At first, it may seem feasible. But problems quickly arise when you start using this component more. What if you need to pass in additional styles to the h2 or the p? What if you need to pass in data- attributes? Umm, you'll probably feel lost in multiple if-else statements. This problem could have been avoided in the first place if we made our components more susceptible to changes.

ViewComponents can be called upon. That means we can use lambda to make our components decoupled from the state.

# app/components/application_component.rb

class ApplicationComponent < ViewComponent::Base
  def initialize(tag: nil, classes: nil, **options)
    @tag = tag
    @classes = classes
    @options = options
  end

  def call
    content_tag(@tag, content, class: @classes, **@options) if @tag
  end

  # helpers
  def class_names(*args)
    classes = []

    args.each do |class_name|
      case class_name
      when String
        classes << class_name if class_name.present?
      when Hash
        class_name.each do |key, val|
          classes << key if val
        end
      when Array
        classes << class_names(*class_name).presence
      end
    end

    classes.compact.uniq.join(" ")
  end
end
Enter fullscreen mode Exit fullscreen mode

We're defining the call method so that we can use our lambda. It's all Rails, so we can probably use content_tag and other view helpers as well. Now let's change our subhead component.

# app/components/subhead_component.rb

class SubheadComponent < ApplicationComponent
  renders_one :heading, lambda { |variant: nil, **options|
    options[:tag] ||= :h2
    options[:classes] = class_names(
      options[:classes],
      "subhead-heading",
      "subhead-heading--danger": variant == "danger",
    )

    ApplicationComponent.new(**options)
  }

  renders_one :description, lambda { |**options|
    options[:tag] ||= :div
    options[:classes] = class_names(
      options[:classes],
      "subhead-description",
    )

    ApplicationComponent.new(**options)
  }

  def initialize(**options)
    @options = options

    @options[:tag] ||= :div
    @options[:classes] = class_names(
      options[:classes],
      "subhead",
    )
  end

  def render?
    heading.present?
  end
end
Enter fullscreen mode Exit fullscreen mode
<%=# app/components/subhead_component.html.erb %>

<%= render ApplicationComponent.new(**@options) do %>
  <%= heading %>
  <%= description %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

I know it looks intimidating at first, but I promise you that you'll be blown away at how reusable the component is.

Using this component is easy, the hard part was making it work.

<%= render SubheadComponent.new(data: { controller: "subhead" }) do |c| %>
  <% c.heading(classes: "more-classes") { "Hey there!" } %>

  <% c.description(tag: :div, variant: "danger") do %>
    My description 
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Now, compare this with what we had earlier. I know right. This is way better than the previous version. Let's build another component.

Your friend, the avatar component

This time we'll be using the inline variant of the ViewComponent.

rails g component avatar --inline
Enter fullscreen mode Exit fullscreen mode

After you run the command, notice that it only generates the .rb file and not the .html.erb file. For simple components, it's fine to just render it from the .rb file itself by making use of the ApplicationComponent.

class AvatarComponent < ApplicationComponent
  def initialize(src:, alt:, size: 9, **options)
    @options = options

    @options[:tag] ||= :img
    @options[:src] = src
    @options[:alt] = alt
    @options[:classes] = class_names(
      options[:classes],
      "avatar rounded-full flex items-center justify-center",
      "avatar--#{size}",
    )
  end

  def call
    render ApplicationComponent.new(**@options)
  end
end
Enter fullscreen mode Exit fullscreen mode

You can now use this component.

<%= render AvatarComponent.new(src: "some url", alt: "your alt attribute", size: 10) %>
Enter fullscreen mode Exit fullscreen mode

As always, you can pass in classes, data attributes, and more. In my opinion, this is a good way to build components. They are segregated from your business logic and allow unit testing, which is advantageous as compared to normal Rails partials.

Building a popover

Popovers are used to bring attention to specific user interface elements, typically to suggest an action or to guide users through a new experience - Primer CSS.

Popover component

We'll be using Stimulus.js to show and hide the popover. If you haven't already, please install Stimulus.js.

// app/javascript/controllers/popover_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
    static targets = ["container"]

    initialize() {
        document.addEventListener("click", (event) => {
            if (this.element.contains(event.target)) return
            this.hide()
        })
    }

    toggle(event) {
        event.preventDefault()
        this.containerTarget.toggleAttribute("hidden")
    }

    hide() {
        this.containerTarget.setAttribute("hidden", "")
    }
}
Enter fullscreen mode Exit fullscreen mode

First, let's add this to our app/components/application_component.rb, so that we can pass in other data attributes without any complexity.

# app/components/application_component.rb

def merge_attributes(*args)
  args = Array.new(2) { Hash.new } if args.compact.blank?
  hashed_args = args.map { |el| el.presence || {} }

  hashed_args.first.deep_merge(hashed_args.second) do |_key, val, other_val|
    val + " #{other_val}"
  end
end
Enter fullscreen mode Exit fullscreen mode

Run rails g component popover and let's get started.

# app/components/popover_component.rb

class PopoverComponent < ApplicationComponent
  DEFAULT_POSITION = :top_left

  POSITIONS = {
    bottom: "popover-message--bottom",
    bottom_right: "popover-message--bottom-right",
    bottom_left: "popover-message--bottom-left",
    left: "popover-message--left",
    left_bottom: "popover-message--left-bottom",
    left_top: "popover-message--left-top",
    right: "popover-message--right",
    right_bottom: "popover-message--right-bottom",
    right_top: "popover-message--right-top",
    top_left: "popover-message--top-left",
    top_right: "popover-message--top-right"
  }.freeze

  renders_one :body, lambda { |caret: DEFAULT_POSITION, **options|
    options[:tag] ||= :div
    options[:classes] = class_names(
      options[:classes],
      "popover-message box p-3 shadow-lg mt-1",
      POSITIONS[caret.to_sym],
    )

    ApplicationComponent.new(**options)
  }

  def initialize(**options)
    @options = options

    @options[:tag] ||= :div
    @options[:classes] = class_names(
      options[:classes],
      "popover",
    )
    @options[:data] = merge_attributes( # we're utilizing the `merge_attributes` helper that we defined earlier.
      options[:data],
      popover_target: "container", # from stimulus controller. Compiles to "data-popover-target": "container"
    )
  end
end
Enter fullscreen mode Exit fullscreen mode
<%=# app/components/popover_component.html.erb %>

<%= render ApplicationComponent.new(**@options, hidden: "") do %>
  <%= body %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Note that we're hiding the popover at first. We'll use stimulus controller to remove this attribute later.

Let's test this component out by using it in our view files.

<div data-controller="popover">
    <button type="button" data-action="popover#toggle">
        Toggle popover
    </button>

    <%= render PopoverComponent.new do |c| %>
      <% c.body(caret: "bottom_right") do %>
        <p>Anything goes inside</p>
      <% end %>
    <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

One thing we can all learn from this component is that, we should not make our components too coupled with other UI's. For example, we could have easily rendered out a button in the component.

<%=# app/components/popover_component.html.erb %>

<%= render ApplicationComponent.new(**@options, hidden: "") do %>
  <button type="button" data-action="popover#toggle">
      Toggle popover
  </button>

  <%= body %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Ask yourself, what are we building? In this case, it's a popover. It should not know about the button or the anchor_tag or any other component that is responsible for showing and hiding the popover component.

Try to make your components as generic as possible. Obviously, there will be some very specific components. For example, if you are rendering out a list of users. You may want that list to fit a particular need, and it's OK.

Making the render method succint

Even if you do not agree with all the things that I've written, you'll mostly agree that render PopoverComponent.new doesn't look that good. Calling a class directly in your views, Ummm, I don't know.

So let's try to simplify it.

# app/helpers/application_helper.rb

def render_component(component_path, collection: nil, **options, &block)
  component_klass = "#{component_path.classify}Component".constantize

  if collection
    render component_klass.with_collection(collection, **options), &block
  else
    render component_klass.new(**options), &block
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, you can use the components like this, render_component "popover", **@options, which in my opinion looks much better and reads much better.

Conclusion

Rails is fun. I like it. If you've found or are using any other ViewComponent patterns in your codebase, please share it in the comments. We'd like to learn more about your approach.

Thank you for reading through and I hope you learned something new today.

References

Edit 1

  • Rename data_attributes method to merge_attributes
  • Make use of deep_merge method that Rails gives us within the merge_attributes method

Discussion (2)

Collapse
amree profile image
Amree Zaid

I wonder why are you using ApplicationComponent in the HTML instead of SubheadComponent itself. I noticed GitHub Primer is doing the same thing. Care to explain?

<%=# app/components/subhead_component.html.erb %>

<%= render ApplicationComponent.new(**@options) do %>
  <%= heading %>
  <%= description %>
<% end %>
Enter fullscreen mode Exit fullscreen mode
Collapse
abeidahmed profile image
Abeid Ahmed Author

Yes, because SubheadComponent class does not have the call method defined. When you're passing in a block, Ruby looks for a call method.
Instead of defining call method for each of the components, you can simplify define in your top level class and refer that class.