Quite often I work with various clients that don’t, or want, or can’t use a third-party library like ViewComponent or similar. That leaves me with partials. Which, granted, often brings me really far early on. But then I hit a wall with maintainability and clean code (mostly too much logic in views which really triggers me). When you read up about this topic, you will often find things like helpers mentioned. The global scope of helpers is my biggest gripe with them. I only reach for one if a helper can truly be used throughout parts of the app.
In this article I want to lay out the various techniques I use for apps I started myself and for others that need more than vanilla Rails partials without relying on gems.
You can view the full repository and I also added a live example of the components on this page.
The Component Helper
The heart of “vanilla Rails Components” is this helper. It provides a clean API for rendering reusable UI elements in your Rails app.
module ComponentHelper
def component(name, locals = {}, &block)
return render(layout: "components/#{name}", locals: locals, &block) if block_given?
collection = locals.delete(:collection)
return render(partial: "components/#{name}", collection: collection, as: locals.delete(:as) || name.to_sym, locals: locals) if collection
render(partial: "components/#{name}", locals: locals)
end
end
This helper wraps Rails’ standard rendering methods with a more concise API. It accepts a component name, a hash of local variables, and an optional block. The helper determines the appropriate rendering strategy based on the arguments provided:
- if a block is given, it renders the component as a layout, passing the block content to the component template;
- if a collection is provided, it renders the component once for each item in the collection;
- otherwise, it renders the component as a simple partial.
All components are expected to be located in the app/views/components/
directory, with filenames prefixed by an underscore (following Rails’ partial naming convention). I still use partials when they are simple enough (like rendering favicons, for example).
Components with Explicit Locals
The simplest components are just partials with a defined interface. Rails 7.2 introduced explicit locals, which clearly indicate which variables are required by a component.
<%# locals: (user:, css: user.avatar_css) %>
<%= tag.span user.avatar.present? ? image_tag(user.avatar, class: "size-full object-cover") : user.name.first.upcase, role: :img, class: css %>
This syntax makes it clear that the user
parameter is required, while css
has a default value (that is taken from the one provided user
object). I mentioned “strict locals”, check out this article to read more.
Using this component is straightforward:
<%= component "avatar", user: User.first.decorate %>
The component can also be used with collections (just like regular partials):
<%= component "avatar", collection: User.all.map(&:decorate), as: :user %>
(I will touch upon the decorate
bit later)
Simple and not much different from using just partials. Lets get to that now.
Components with Content Blocks
Some components need to wrap content. This is where the component-helper shines. The section component (used on the root page to preview the components. How meta!) provides a consistent layout for different content sections:
<%# locals: (name:) %>
<li>
<%= tag.h2 name, class: "text-base font-bold text-gray-800" %>
<div class="mt-1 p-4 border border-gray-200 rounded-md">
<%= yield %>
</div>
</li>
The breadcrumbs component (taken from a real app too) is another example:
<%# locals: (items: []) %>
<nav aria-label="Breadcrumb">
<ol class="flex items-center gap-x-1 text-sm font-semibold" itemscope itemtype="https://schema.org/BreadcrumbList">
<% items.each.with_index do |item, index| %>
<li class="flex items-center" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<%= link_to item[:label], item[:href], class: "text-gray-800 hover:underline", itemprop: "item" %>
<%= tag.meta itemprop: "name", content: item[:label] %>
<%= tag.meta itemprop: "position", content: index + 1 %>
<span class="inline-block ml-1 font-medium text-gray-500" aria-hidden="true">/</span>
</li>
<% end %>
<li class="text-gray-600" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" aria-current="page">
<span itemprop="name"><%= yield %></span>
<%= tag.meta itemprop: "position", content: items.length + 1 %>
</li>
</ol>
</nav>
Just pass it a hash with a label
and href
. The block
({ "Here" }
) is the last item of the breadcrumbs:
<%= component("breadcrumbs", items: [{label: "Rails Designer", href: "https::/railsdesigner.com"}, {label: "Articles", href: "https://railsdesigner.com/articles/"}]) { "Here" } %>
Components with Decorators
As mentioned earlier, the avatar component uses a decorator to provide view-specific methods for the User model:
class User::Decorator < SimpleDelegator
include ActionView::Helpers::TagHelper
def avatar_css(size = :md)
class_names(
"flex items-center justify-center font-semibold text-gray-600 border border-gray-300/50 overflow-clip rounded-full",
{
"size-5 text-sm": size == :sm,
"size-6 text-base": size == :md,
"size-8 text-ll": size == :lg,
"size-10 text-xl": size == :xl
}
)
end
end
The decorator is applied to User objects using the decorate
method. This pattern keeps view-specific logic separate from the model while still making it easily accessible in the view. For more details on decorators, check out this article I wrote about it earlier.
Class-based Components
For other (more complex) components, it can be helpful to move logic into a dedicated Ruby class. The badge component demonstrates this approach:
class BadgeComponent
include ActionView::Helpers::TagHelper
def initialize(name)
@name = name
raise "Incorrect badge name" if NAMES.exclude? name
end
def name
NAMES[@name]
end
def css
class_names(
"inline-block px-3 py-0.5",
"text-xs font-medium",
"ring ring-offset-0 border border-white/24 rounded-full",
COLORS[@name]
)
end
private
NAMES = {
webmaster: "Webmaster Supreme",
pwru: "Power User 9000",
# …
}
COLORS = {
webmaster: "bg-blue-100 text-blue-800 ring-blue-200",
pwru: "bg-purple-100 text-purple-800 ring-purple-200",
# …
}
end
The corresponding view template is simple:
<%# locals: (name:, badge: BadgeComponent.new(name)) %>
<%= tag.span badge.name, class: badge.css %>
This component is used like this:
<%= component("badge", name: :webmaster) %>
See how only a name
is passed along and the component itself uses instances of BadgeComponent
.
The Ruby class cam encapsulate the more complex component logic. This can make it easier to test and maintain. The view template focuses solely on rendering the component’s HTML.
The class technique gets you quite close to what ViewComponent gives you!
Examples
The VanillaComponents repository contains all these example components that demonstrate different approaches to component design:
<%= component "avatar", user: User.first.decorate %>
<%= component "avatar", collection: User.all.map(&:decorate), as: :user %>
<%= component("breadcrumbs", items: [{label: "Rails Designer", href: "https::/railsdesigner.com"}, {label: "Articles", href: "https://railsdesigner.com/articles/"}]) { "Here" } %>
<%= component("input_toggle", value: "my secret") %>
<%= component("badge", name: :webmaster) %>
These examples show how the component helper can be used to create a variety of UI elements, from simple avatars to more complex interactive components.
By following these patterns, I have created maintainable component systems for various clients without depending on another gem.