An Interest In:
Web News this Week
- March 19, 2024
- March 18, 2024
- March 17, 2024
- March 16, 2024
- March 15, 2024
- March 14, 2024
- March 13, 2024
Advanced ViewComponent patterns in Rails
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>
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.
# Gemfilegem "view_component", require: "view_component/engine"
After you've installed the gem
, create a new file at app/components/application_component.rb
.
# app/components/application_component.rbclass ApplicationComponent < ViewComponent::Baseend
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.
rails g component subhead
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.rbclass SubheadComponent < ApplicationComponent def initialize(title:, description: nil, danger: false) @title = title @description = description @danger = danger end def render? @title.present? endend
<%=# app/components/subhead_component.html.erb %><div> <h2><%= @title %></h2> <p class="<%= @danger ? 'subhead--danger' : 'some other class' %>"> <%= @description %> </p></div>
And then, you can use this component in your .erb
files, by calling,
<%= render SubheadComponent.new(title: "something", description: "subhead description")
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.rbclass 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(" ") endend
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.rbclass 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? endend
<%=# app/components/subhead_component.html.erb %><%= render ApplicationComponent.new(**@options) do %> <%= heading %> <%= description %><% end %>
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 %>
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
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) endend
You can now use this component.
<%= render AvatarComponent.new(src: "some url", alt: "your alt attribute", size: 10) %>
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.
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.jsimport { 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", "") }}
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.rbdef data_attributes(**args) args_without_attributes = args.except(:attributes) args[:attributes] = {} if args[:attributes].nil? args_without_attributes.each_key do |attr| if args[:attributes].keys.include?(attr) args[:attributes][attr] += " #{args[attr]}" else args[:attributes].merge!(Hash[attr, args[attr]]) end end args[:attributes]end
We'll also make adjustments to the initialize
method and the call
method in our application_component.rb
file.
def initialize(tag:, classes: nil, data: nil, **options) @tag = tag @classes = classes @data = data @options = optionsenddef call content_tag(@tag, content, class: @classes, data: @data, **@options) if @tagend# Note that we're just accepting an additional `data` attribute.
Run rails g component popover
and let's get started.
# app/components/popover_component.rbclass 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] = data_attributes( # we're utilizing the `data_attributes` helper that we defined earlier. attributes: options[:data], popover_target: "container", # from stimulus controller. Compiles to "data-popover-target": "container" ) endend
<%=# app/components/popover_component.html.erb %><%= render ApplicationComponent.new(**@options, hidden: "") do %> <%= body %><% end %>
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>
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 %>
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.rbdef 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 endend
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
Original Link: https://dev.to/abeidahmed/advanced-viewcomponent-patterns-in-rails-2b4m
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To