ViewComponent in the Wild II: supercharging your components
Translations
GitHub’s ViewComponent library has been around for some time, helping developers stay sane while building the view layer of their Ruby on Rails applications. It’s steadily grown in popularity—but not as quickly as probably deserved. In this series, we’ll lay out why you need to give it a shot, too. We’ll discuss some best practices and examine various tips and tricks that we’ve accumulated in our ViewComponent-powered projects at Evil Martians.
Other parts:
- ViewComponent in the Wild I: building modern Rails frontends
- ViewComponent in the Wild II: supercharging your components
- ViewComponent in the Wild III: TailwindCSS classes & HTML attributes
In the previous part of this series, we established that using the component approach to build the view layer on the backend is pretty …wild! Further, we even learned how to properly apply this approach, but we didn’t see how it’s actually used in the wild (meaning production, of course). Now it’s time to fill in the gaps.
This time, we’ll finally dig into all the nooks and crannies of our ViewComponent setup (and even a little bit more). You’ll learn how to bend view components to your will—the Martian way. Unlike the previous post, there will be a lot of code, so buckle up!
Table of contents:
Supercharging your components
Although ViewComponent does what it should (and does it well), it doesn’t exactly take you by the hand like Rails does. It still suffers from a lack of conventions; some things you have no choice but to figure out on your own. But fear not; in this chapter I’d like to save your time and show how we structure code around view components at Evil Martians, so you can start being productive with them in no time.
Please, note that the majority of techniques presented in this article can be considered “off-label” to ViewComponent. This is how we cook view components at Evil Martians, so naturally it’s going to be very opinionated. However, there are plans to merge some of this stuff upstream, so keep an eye out! 😉
view_component-contrib
But first, let me introduce the view_component-contrib gem which we’re going to build on in this article. It’s a collection of extensions and patches for ViewComponent that we’ve found useful while working on different projects. It’ll take care of most low-level stuff so we can focus on the meat without having to think about how the meat is made. You can install it with a single command:
rails app:template LOCATION="https://railsbytes.com/script/zJosO5"
This will launch a configuration wizard to set up things as you’d prefer. (If you’re unsure how to answer some of the questions, keep on reading and you may find your answer!)
From this point on I’m going to assume you have it installed.
Folder structure
The amazing thing about Rails (and similar frameworks) is that we rarely have to think about where to put what: models go into app/models
, controllers into app/controllers
, and so on. But where do view components go? And where should we store all the related files (assets, translations, previews, and so on)?
The ViewComponent documentation suggests using the app/components
folder, but I think that might be a bit misleading (it’s too general and nothing about this says it’s connected to the view layer). Moreover, wouldn’t you want to keep all the frontend-related stuff together (which, if you use such conventions, is usually located in app/views
, or app/frontend
)? For this reason, I much prefer keeping components in app/views/components
.
However, since Rails expects the controller and mailer views to also be in app/views
by default, that folder might get messy quickly (and there might even be name collisions). To keep it nice and tidy, let’s namespace the views to their corresponding subfolders:
views/
components/
layouts/
controllers/
my_controller/
index.html.erb
mailers/
my_mailer/
message.html.erb
To do that, add this line to your ApplicationController
:
append_view_path Rails.root.join("app", "views", "controllers")
Do the same for ApplicationMailer
:
append_view_path Rails.root.join("app", "views", "mailers")
Now, let’s take a peek inside the app/views/components
folder:
components/
example/
component.html.erb (this is our template)
component.rb (Example::Component class)
preview.rb (Example::Preview class)
styles.css (CSS styles)
whatever.png (other assets)
This is how it should look if you use view_component-contrib
(otherwise, it’s not as pretty). The component.rb
and component.html.erb
(you can use any other template engine, of course) files are obviously required, but everything else is optional. Note how everything the component needs to work is nicely stored in a single folder. Rejoice, my fellow perfectionists!
Oh, and if desired, there’s nothing stopping us from namespacing our components to subfolders:
components/
way_down/
we_go/
example/
component.rb (WayDown::WeGo::Example::Component class)
preview.rb (WayDown::WeGo::Example::Preview class)
Helpers
This is how components are rendered out of the box:
<%= render(Example::Component.new(title: "Hello World!")) %>
That’s not too bad, but it quickly becomes repetitive. Let’s save ourselves some typing and carpal tunnel by adding a little sugar to ApplicationHelper
:
def component(name, *args, **kwargs, &block)
component = name.to_s.camelize.constantize::Component
render(component.new(*args, **kwargs), &block)
end
And now, we can do this:
<%= component "example", title: "Hello World!" %>
Or, if we use namespaces:
<%= component "way_down/we_go/example", title: "Hello World!" %>
Base classes
It’s common practice to create abstract base classes for each entity type in the application in order to make extending the framework easier without resorting to monkey-patching (e.g. ApplicationController
, ApplicationMailer
, etc.).
There’s no reason we shouldn’t do the same for components:
# app/views/components/application_view_component.rb
class ApplicationViewComponent < ViewComponentContrib::Base
extend Dry::Initializer
include ApplicationHelper
end
By adding the dry-initializer we move from imperative #initialize
methods to declarative code while also saving us from a lot of boilerplate in the future. As for include ApplicationHelper
, we need it to reuse the defined component
helper in the component templates and previews above.
This is how the base class for previews might look:
# app/views/components/application_view_component_preview.rb
class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
# Hides this class from previews index
self.abstract_class = true
# Layouts are inherited (but can be overriden)
layout "component_preview"
end
Effects
In the previous article, we learned that global state has to be passed as context and that it can be achieved using dry-effects. Let’s see how this can be done in practice by making the current_user
available globally.
All you need to do is to add this to your ApplicationController
:
include Dry::Effects::Handler.Reader(:current_user)
around_action :set_current_user
private
def set_current_user
# Assuming you have `#current_user` method defined:
with_current_user(current_user) { yield }
end
And this to your ApplicationViewComponent
:
include Dry::Effects.Reader(:current_user, default: nil)
Now, whenever you need to get the current user you can just call the #current_user
method anywhere inside any component. That’s it!
However, production code is not the only place where we need to provide context like this. In the previous post, we learned how to test components in isolation, and if you have a good memory you remember that we used the very same #with_current_user
helper in our tests there. Of course, this will have to be set up separately.
This is how your RSpec configuration might look:
# spec/support/view_component.rb
require "view_component/test_helpers"
require "capybara/rspec"
RSpec.configure do |config|
config.include ViewComponent::TestHelpers, type: :view_component
config.include Capybara::RSpecMatchers, type: :view_component
config.include Dry::Effects::Handler.Reader(:current_user), type: :view_component
config.define_derived_metadata(file_path: %r{/spec/views/components}) do |metadata|
metadata[:type] = :view_component
end
end
Nesting
We’ve already established that you can namespace components, which is helpful to prevent bloating the app/views/components
folder. Another technique you can utilize that serves the same purpose: nesting components (in this case, keeping child components right in the parent component folder). After all, if you’re dead sure that a component won’t ever be used outside of the parent component, there’s no reason to put it into the root folder.
Now, if you nest your component inside another component and try to render it by its full name (e.g. my_parent/my_child
) it will work just fine, but we can go a little further and allow the using of relative names within the parent component.
Let’s add the following code to our ApplicationViewComponent
:
class << self
def component_name
@component_name ||= name.sub(/::Component$/, "").underscore
end
end
def component(name, ...)
return super unless name.starts_with?(".")
full_name = self.class.component_name + name.sub('.', '/')
super(full_name, ...)
end
And now you can do this:
<%= component ".my-nested-component" %>
Just go easy on this technique: deep nesting has its own drawbacks—sometimes a flat folder structure is just what you need.
I18n
ViewComponent has out-of-the-box I18n support. This allows you to have isolated localization files for each component. However, if you prefer to have a centralized store for your translations, view_component-contrib
provides an alternative way of doing this: namespacing. Either way, you get to use relative paths.
Consider that you have a config/locales/en.yml
file like this:
en:
view_components:
way_down:
we_go:
example:
title: "Hello World!"
You can reference it inside your way_down/we_go/example
component like so:
<!-- app/views/components/way_down/we_go/example/component.html.erb -->
<h1><%= t(".title") %></h1>
CSS
We store all related assets inside the component folder in our setup, but in fact, our Ruby app has no idea that they’re there. It’s your assets pipeline’s job to bundle them properly—while this is another topic entirely, since we use CSS classes inside our component templates, it deserves a discussion.
CSS is global by nature; this makes it a bit difficult to use with components, which are isolated by design. We want to scope CSS classes to our components and prevent all possible name collisions, so we can’t just concatenate all the styles.css
files inside our components into a single, huge CSS file. There are two general ways around this problem.
One way is to use conventions such as BEM, or just name your CSS classes in a way that would rule out all possible name collisions. For example, you can prefix all your CSS classes with c--component-name--
(c
stands for component
here). However, this adds an additional cognitive load on developers and it gets repetitive with time.
You may also be familiar with the CSS modules approach which achieves isolation by transforming CSS class names into unique identifiers during the bundling process so that developers don’t even need to think about it when writing the code. Unfortunately, although it works well for JavaScript, there’s no easy way to (currently, at least) do the same in Ruby because we don’t bundle Ruby sources.
So, where does this leave us? Well, we can’t use randomized identifiers as CSS class names, but it doesn’t mean we have to resort to manually writing c--component-name--
every time. We can make the bundling process do that for us. How exactly this will happen will depend on your assets pipeline configuration, but the core idea is to make it automatically generate CSS class names that follow our naming convention.
For the sake of example, let’s assume that we use PostCSS to bundle our CSS files. In this case, we can make use of the postcss-modules
package. First, install it (yarn add postcss-modules
in case you use Yarn) and then add the following code to your postcss.config.js
:
module.exports = {
plugins: {
'postcss-modules': {
generateScopedName: (name, filename, _css) => {
const matches = filename.match(/\/app\/views\/components\/?(.*)\/index.css$/)
// Don't transform CSS files from outside of the components folder
if (!matches) return name
// Transforms "way_down/we_go/example" into "way-down--we-go--example"
const identifier = matches[1].replaceAll('_', '-').replaceAll('/', '--')
return `c--${identifier}--${name}`
},
// Don't generate *.css.json files (we don't need them)
getJSON: () => {}
}
}
}
Of course, we need to follow the same naming convention in our components’ templates. To make our lives easier, let’s add the following helper to our ApplicationViewComponent
:
class << self
def identifier
@identifier ||= component_name.gsub("_", "-").gsub("/", "--")
end
end
def class_for(name)
"c--#{self.class.identifier}--#{name}"
end
And then, if you have the following CSS class:
/* app/views/components/example/styles.css */
.container {
padding: 10px;
}
You can reference it in the component’s template like this:
<!-- app/views/components/example/component.html.erb -->
<div class="<%= class_for("container") %>">
Hello World!
</div>
That’s it! Now you can safely use any CSS class name and they will be automatically scoped to the components they belong to.
Although I have to mention, if you’re using Tailwind (or a similar CSS framework), you might not even need all of this because there’s a high chance your styling needs would be fully covered by the built-in classes.
JS
Some things in life never change: the sun rises in the east, taxes are a fact of life, and to make an interactive interface—you need JavaScript. That being said, if you don’t want to, you don’t have to write it yourself. As I briefly mentioned in the previous article, by using the Hotwire stack (specifically Turbo), you can avoid writing any JavaScript for a long time and still get a responsive web application that feels alive.
However, at some point, you’d want to add some sprinkles to your UI, and Stimulus is the perfect tool for this. It allows you to easily attach dynamic behavior (defined in the Stimulus controller class) to HTML elements via custom data-controller
attributes. Let’s take the example from the Stimulus documentation and turn it into a component in our application.
First, we create the Stimulus controller class (usually, a single controller.js
per component is quite sufficient):
// app/views/components/hello/controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["name"]
greet() {
const element = this.nameTarget
const name = element.value
console.log(`Hello, ${name}!`)
}
}
Then, we connect it to our HTML template via data
attributes:
<!-- app/views/components/hello/component.html.erb -->
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="click->hello#greet">Greet</button>
</div>
And, finally, we glue everything together somewhere in the application entrypoint (this heavily depends on how your assets pipeline is configured):
// app/assets/javascripts/application.js
import { Application } from "@hotwired/stimulus"
import HelloController from "../../views/components/hello/controller"
window.Stimulus = Application.start()
Stimulus.register("hello", HelloController)
This will work, but it does leave a lot to be desired. First of all, we want to infer controller names and automatically register them to their corresponding controller classes. Again, this would heavily depend on your assets pipeline configuration, but assuming we use Vite, it can look something like this:
// app/assets/javascripts/application.js
import { Application } from '@hotwired/stimulus'
const application = Application.start()
window.Stimulus = application
const controllers = import.meta.globEager(
"./../../app/views/components/**/controller.js"
)
for (let path in controllers) {
let module = controllers[path]
let name = path
.match(/app\/views\/components\/(.+)\/controller\.js$/)[1]
.replaceAll("_", "-")
.replaceAll("/", "--")
application.register(name, module.default)
}
Here we collect all controller.js
files from all components and glue them to the Stimulus controller names inferred from the component folder paths. And all of this happens during the bundling process.
Now, if you’ve paid attention, you’ve probably noticed that the controller names are inferred in a very similar way we defined the ::identifier
method in the previous section. This is no coincidence: just as with CSS, there’s no direct connection between our bundling process and our Ruby app, so we have to rely on naming conventions.
Let’s add the following helper to our ApplicationViewComponent
:
def controller_name
self.class.identifier
end
And now, instead of manually writing controller names in the data
attributes inside our templates (and trying to keep them in sync), we can do this:
<!-- app/views/components/hello/component.html.erb -->
<div data-controller="<%= controller_name %>">
<input data-<%= controller_name %>-target="name" type="text">
<button data-action="click-><%= controller_name %>#greet">Greet</button>
</div>
Generators
We’ve spent a large portion of this article using various tips and tricks to try and make our lives easier by reducing the boilerplate code, but not all boilerplate code is supposed to be eliminated (ask Go developers 😉).
In the context of view components, for example, we still need to create a bunch of files every time we want to add a new view component (previews, specs, the component class itself, etc.). Of course, doing this manually is tiresome, so ViewComponent provides some out-of-the-box generators. All you have to do is:
bin/rails g component Example
However, a generator would only be useful if it fits your project needs. That’s why view_component-contrib
creates custom generators during installation that you can checkout into the repository and modify to meet your needs. By making generators a part of the project, you gain more control over your workflow.
Runtime linters
Last but not least, let’s see how we can enforce some of the best practices we established in the previous article—specifically, the recommendation to avoid making database queries in view components.
While some best practices are better enforced with build time linters (for example, via custom Rubocop rules), with others (like the one we’re interested in) it makes more sense to use a runtime linter. Luckily for us, ViewComponent provides ActiveSupport instrumentation that can help us make one.
Let’s enable the instrumentation first:
# config/application.rb
config.view_component.instrumentation_enabled = true
And then, let’s add our custom configuration option to the development.rb
and test.rb
environments which will allow us to catch misbehaving view components in development while running tests:
config.view_component.raise_on_db_queries = true
However, it would be pretty rough to enforce this policy without leaving some amount of leeway, so let’s allow components to opt out of this if they really need to. To do this, add this to your ApplicationViewComponent
:
class << self
# To allow DB queries, put this in the class definition:
# self.allow_db_queries = true
attr_accessor :allow_db_queries
alias_method :allow_db_queries?, :allow_db_queries
end
Now, all that’s left to do is to implement the linter itself:
# config/initializers/view_component.rb
if Rails.application.config.view_component.raise_on_db_queries
ActiveSupport::Notifications.subscribe "sql.active_record" do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Thread.current[:last_sql_query] = event
end
ActiveSupport::Notifications.subscribe("!render.view_component") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
last_sql_query = Thread.current[:last_sql_query]
next unless last_sql_query
if (event.time..event.end).cover?(last_sql_query.time)
component = event.payload[:name].constantize
next if component.allow_db_queries?
raise <<~ERROR.squish
`#{component.component_name}` component is not allowed to make database queries.
Attempting to make the following query: #{last_sql_query.payload[:sql]}.
ERROR
end
end
end
Of course, you can use this technique to enforce other things as well—the only limit is your imagination.
Setting up a storybook! (Bonus)
At this point you should be equipped with enough knowledge to be able to start confidently using ViewComponent in your projects. However, there’s one topic yet that I’ve intentionally left out so far since its optional: previews. Still, don’t let the word “optional” cause you to skip this part because, let me tell you, previews are one of the most useful tools in your components toolbox.
The many benefits of using previews
How many times have you wondered how to split a large task into smaller chunks so that you can finally sit down and work on something tangible? Too many, personally. Luckily, it doesn’t have to be this way when working with view code: this is thanks to component previews, they allow us to create and test components in isolation, and it’s hardly a problem. All you need to do is to mock up some data, and then go see how your new component appears in the browser.
Previews allow us to work on view components in isolation.
But it doesn’t stop there! You can make previews for all kinds of scenarios involving the component and test all possible edge cases. And, if you do the same for every component in your application, you basically get the live documentation for free. Not only developers would find this helpful, but the whole team, too! Oh, and did you know you can use previews as test cases in your unit tests, as well? Pretty neat, huh?
Looking for Lookbook
ViewComponent already provides a way to see component previews in the browser (via the /rails/view_components
route), but it’s very basic. Wouldn’t it be nice if our storybook had more features like searching, categories, dynamic parameters, and so on? In frontend world, there’s Storybook.js which supports all of that and more—but do we have something like that for Ruby?
Sure we do—it’s called Lookbook! We’ve used it in a recent project at Evil Martians (with great success) and now we’re happy to share some tips & tricks we’ve learned along the way.
Basic setup
First, let’s add the gem to our Gemfile
:
gem "lookbook", require: false
We don’t require it by default (to avoid running file watchers in production). To enable it in development and/or staging, we can use the LOOKBOOK_ENABLED
environment variable, for example.
Unfortunately, since the Lookbook engine needs to be loaded right after Rails configuration to register its initializers (and there is no such hook in Rails) we’re left with only one way to conditionally require it during setup:
# config/application.rb
config.lookbook_enabled = ENV["LOOKBOOK_ENABLED"] == "true" || Rails.env.development?
require "lookbook" if config.lookbook_enabled
Now, let’s add the route to routes.rb
(alternatively, you can add it to config/routes/development.rb
if you prefer to separate production and development routes):
if Rails.application.config.lookbook_enabled
mount Lookbook::Engine, at: "/dev/lookbook"
end
We’re almost done here, the only thing left is to set up effects for previews as well. Remember, how we injected the current_user
value in ApplicationController
and resolved it inside our components? Well, here we need to do something different because previews are rendered in a different controller that has nothing to do with our ApplicationController
.
To spare you the gory details, this is how the entire setup can look:
# app/views/components/application_view_component_preview.rb
class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
# See https://github.com/lsegal/yard/issues/546
send :include, Dry::Effects.State(:current_user)
def with_current_user(user)
self.current_user = user
block_given? ? yield : nil
end
end
# config/initializers/view_component.rb
ActiveSupport.on_load(:view_component) do
ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable
ViewComponent::Preview.extend ViewComponentContrib::Preview::Abstract
if Rails.application.config.lookbook_enabled
Rails.application.config.to_prepare do
Lookbook::PreviewsController.class_eval do
include Dry::Effects::Handler.State(:current_user)
around_action :nullify_current_user
private
def nullify_current_user
with_current_user(nil) { yield }
end
end
end
end
end
And now, you have the handy #with_current_user
method that you can use in your previews (hooray!):
class Example::Preview < ApplicationViewComponentPreview
def default
with_current_user(User.new(name: "Handsome"))
end
end
Opinionated previews
There are several ways to render components in previews: you can either use the default preview template provided by view_component-contrib
, render components manually within ::Preview
class instance methods (called examples, by the way), or you can create a separate .html.{erb,slim,etc}
template for each example and render component there in the exact same manner you would in any other part of the application. On the last project, we went with the last option and didn’t regret it one bit.
In our setup, each component has a preview.html.erb
which is used as a default preview template for all examples of the component, and we could also add a bunch of example-specific preview templates in the previews
subfolder. Let’s take a look at how we can write previews for the Collapsible
component from the video above:
# app/views/components/collapsible/preview.rb
class Collapsible::Preview < ApplicationViewComponentPreview
# @param title text
def default(title: "What is the goal of this product?")
render_with(title:)
end
# @param title text
def open(title: "Why is it open already?")
render_with(title:)
end
end
The @param
tag above tells Lookbook to treat it as a dynamic parameter that we can modify in real-time when browsing our Lookbook. There are a bunch of other tags available, so feel free to check out the Lookbook documentation if you want to learn more.
And here’s how preview templates might appear:
<!-- app/viewc/components/collapsible/preview.html.erb -->
<%= component "collapsible", title: do %>
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid.
<% end %>
<!-- app/viewc/components/collapsible/previews/open.html.erb -->
<%= component "collapsible", title:, open: true do %>
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid.
<% end %>
Note how it’s exactly the same code we would use if we rendered the component within a typical view or another component’s template (only in this case, local variables, like title
, come from the #render_with
method).
Sure, at first, it might look a little “boilerplate-y” to create a separate preview template for each component, but in exchange, you get the full freedom to choose exactly how each component is represented and further, you can fiddle with it at any time without breaking other component previews.
That being said, what’s probably even more important is that components are rendered in the exact same way across all your codebase—whether it via previews or on the production code (and speaking from experience, this makes frontend developers on the project very happy).
Previews for mailers
We already have previews for components, but why stop now? There a lot of things that could benefit from having a preview.
Let’s say we have the following mailer:
# app/mailers/test_mailer.rb
class TestMailer < ApplicationMailer
def test(email, title)
@title = title
mail(to: email, subject: "This is a test email!")
end
end
<!-- app/views/mailers/test_mailer/test.html.erb -->
<% content_for :content do %>
<h1><%= @title %></h1>
<% end %>
And here’s the layout shared by all the mailers in our application:
<!-- app/views/layouts/mailer.html.erb -->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<%= stylesheet_link_tag "email" %>
</head>
<body>
<div class="container">
<%= yield :content %>
</div>
</body>
</html>
Nothing unusual so far, just some typical Action Mailer code.
Let’s spice things up by adding a base preview class for mailers (notice the @hidden
tag that will prevent this class showing up in Lookbook):
# app/views/components/mailer_preview/preview.rb
# This preview is used to render mailer previews.
#
# @hidden
class MailerPreview::Preview < ApplicationViewComponentPreview
layout "mailer_preview"
def render_email(kind, *args, **kwargs)
email = mailer.public_send(kind, *args, **kwargs)
{
locals: {email:},
template: "mailer_preview/preview",
source: email_source_path(kind)
}
end
private
def mailer
mailer_class = self.class.name.sub(/::Preview$/, "").constantize
mailer_params ? mailer_class.with(**mailer_params) : mailer_class
end
def email_source_path(kind)
Rails.root.join("app", "views", "mailers", mailer.to_s.underscore, "#{kind}.html.erb")
end
def mailer_params = nil
end
The idea is to automatically infer the mailer class when inheriting from the one above, then render the email with provided params, and inject the output into our custom preview template for emails (mailer_preview/preview
). If needed, the mailer_params
method is supposed to be implemented by descendants of this class.
Also, pay attention to the source
key we’re returning from render_email
: it’s a custom key which neither ViewComponent nor Lookbook are aware of (yet). It contains the full path to the email template and, a bit later, we’re going to use that to adjust the Source
tab in Lookbook, so stay tuned.
Alright, here is the preview template:
<!-- app/views/components/mailer_preview/preview.html.erb -->
<header>
<dl>
<% if email.respond_to?(:smtp_envelope_from) && Array(email.from) != Array(email.smtp_envelope_from) %>
<dt>SMTP-From:</dt>
<dd id="smtp_from"><%= email.smtp_envelope_from %></dd>
<% end %>
<% if email.respond_to?(:smtp_envelope_to) && email.to != email.smtp_envelope_to %>
<dt>SMTP-To:</dt>
<dd id="smtp_to"><%= email.smtp_envelope_to %></dd>
<% end %>
<dt>From:</dt>
<dd id="from"><%= email.header['from'] %></dd>
<% if email.reply_to %>
<dt>Reply-To:</dt>
<dd id="reply_to"><%= email.header['reply-to'] %></dd>
<% end %>
<dt>To:</dt>
<dd id="to"><%= email.header['to'] %></dd>
<% if email.cc %>
<dt>CC:</dt>
<dd id="cc"><%= email.header['cc'] %></dd>
<% end %>
<dt>Date:</dt>
<dd id="date"><%= Time.current.rfc2822 %></dd>
<dt>Subject:</dt>
<dd><strong id="subject"><%= email.subject %></strong></dd>
<% unless email.attachments.nil? || email.attachments.empty? %>
<dt>Attachments:</dt>
<dd>
<% email.attachments.each do |a| %>
<% filename = a.respond_to?(:original_filename) ? a.original_filename : a.filename %>
<%= link_to filename, "data:application/octet-stream;charset=utf-8;base64,#{Base64.encode64(a.body.to_s)}", download: filename %>
<% end %>
</dd>
<% end %>
</dl>
</header>
<div name="messageBody">
<%== email.decoded %>
</div>
Oh, and don’t forget about the basic layout:
<!-- app/views/layouts/mailer_preview.html.erb -->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<style type="text/css">
html, body, iframe {
height: 100%;
}
body {
margin: 0;
}
header {
width: 100%;
padding: 10px 0 0 0;
margin: 0;
background: white;
font: 12px "Lucida Grande", sans-serif;
border-bottom: 1px solid #dedede;
overflow: hidden;
}
dl {
margin: 0 0 10px 0;
padding: 0;
}
dt {
width: 80px;
padding: 1px;
float: left;
clear: left;
text-align: right;
color: #7f7f7f;
}
dd {
margin-left: 90px; /* 80px + 10px */
padding: 1px;
}
dd:empty:before {
content: "\00a0"; //
}
iframe {
border: 0;
width: 100%;
}
</style>
</head>
<body>
<%= yield %>
</body>
</html>
The only thing left is to tell Rails where it can find previews for our emails so that Lookbook can pick them up (we store them alongside email templates):
# config/application.rb
config.view_component.preview_paths << Rails.root.join("app", "views", "mailers")
Whew! That was a lot, but at least now we can inherit from the MailerPreview::Preview
class and render our emails with just a single line of code:
# app/views/mailers/test_mailer/preview.rb
class TestMailer::Preview < MailerPreview::Preview
# @param body text
def default(body: "Hello World!")
render_email(:test, "john.doe@example.com", body)
end
end
And voilà, we have our mailer previews:
Previews for frontend components
In one of our recent projects at Evil Martians, we decided to stray away from the conventional SPA vs. MPA path and instead landed on a hybrid solution: most of the app interface was written with ViewComponent, but some (especially frontend) part of it was written as a React app. It was a very thoughtful solution at the time because it allowed us to distribute the work evenly between team members (so that frontend wouldn’t become a bottleneck). This helped us keep the complexity at bay (as opposed to going full SPA with a GraphQL API on backend, or whatever).
Everything went smoothly, but there was one issue: we didn’t want to have two separate storybooks in one project, so we decided to adopt Lookbook for our frontend components written in React, too. I’m going to show you how we did it, but first, take a look at how our folder structure looked:
app/
views/
components/ (ViewComponent components)
frontend/
components/ (React components)
Example/
previews/ (example-specific previews)
something.tsx
index.tsx (React component)
preview.rb (Frontent::Example::Preview)
preview.tsx (default preview)
As you can see, the structure of the frontend component folder is very similar to the one we use on the backend, only preview files have .tsx
extension instead of html.erb
. The idea is to write frontend previews as React components that we bundle separately and dynamically inject into Lookbook.
Here’s preview.tsx
:
// frontend/components/Example/preview.tsx
import * as React from 'react'
import Example from './index'
interface Params {
title: string
}
export default ({ title }: Params): JSX.Element => {
return (
<Example title={title} />
)
}
And this is preview.rb
:
# frontend/components/Example/preview.rb
class Frontend::Example < ReactPreview::Preview
# @param title text
def default(title: "Hello World!")
render_with(title:)
end
def something
end
end
Of course, we also need to tell Rails where to look for these:
# config/application.rb
config.view_component.preview_paths << Rails.root.join("frontend", "components")
And sure enough, it works:
Looks nice, doesn’t it? But to make it work like this, we need to add a lot of glue code, so buckle up! Let’s start with the base class for React component previews:
# app/views/components/react_preview/preview.rb
require "json"
# Define namespace for React component previews.
module Frontend; end
# This preview is used to render React component previews.
#
# @hidden
class ReactPreview::Preview < ApplicationViewComponentPreview
layout "react_preview"
class << self
def render_args(example, ...)
super.tap do |result|
result[:template] = "react_preview/preview"
result[:source] = preview_source_path(example)
result[:locals] = {
component_name: react_component_name,
component_props: result[:locals].to_json,
component_preview: example
}
end
end
private
def react_component_name
name.sub(/^Frontend::/, "")
end
def preview_source_path(example)
base_path = Rails.root.join("frontend", "components", react_component_name)
if example == "default"
base_path.join("preview.tsx")
else
base_path.join("previews", "#{example}.tsx")
end
end
end
end
And here’s the react_preview/preview
template:
<!-- app/views/components/react_preview/preview.html.erb -->
<script>
window.componentName = '<%= component_name %>'
window.componentPreview = '<%= component_preview %>'
window.componentProps = <%= raw(component_props) %>
</script>
Here, we override the ViewComponent internal method render_args
to accomplish exactly one thing: by passing it through browser’s global variables, we provide our frontend bundle with all the data it needs to render a specific preview. As you can see, we infer the React component name from the Ruby preview class (Frontend::Example
→ Example
), and we collect all arguments that were passed with render_with
into a single JSON that will serve as component props. Also, there’s that custom :source
property from the last section again, but this time, it contains the full path to our preview .tsx
files (keep at eye on it, we’re going to need it later).
Cool! Now that we have everything we need to render the component, it’s time to actually do that. Again, this is going to be very specific to your assets pipeline configuration, but with Vite, it could look something like this:
<!-- app/views/layouts/react_preview.html.erb -->
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= vite_client_tag %>
<%= vite_react_refresh_tag %>
<%= vite_javascript_tag "preview" %>
</head>
<body>
<div id="preview-container">
<%= yield %>
</div>
</body>
</html>
And here’s the actual glue code:
// frontend/entrypoints/preview.js
import { createRoot } from 'react-dom/client'
import { createElement } from 'react'
const defaultPreviews = import.meta.globEager('../components/*/preview.tsx')
const namedPreviews = import.meta.globEager('../components/*/previews/*.tsx')
function previewModule(componentName, previewName) {
if (previewName === 'default') {
return defaultPreviews[`../components/${componentName}/preview.tsx`].default
} else {
return namedPreviews[
`../components/${componentName}/previews/${previewName}.tsx`
].default
}
}
const container = document.getElementById('preview-container')
const root = createRoot(container)
const element = createElement(
previewModule(window.componentName, window.componentPreview),
window.componentProps
)
root.render(element)
I know it’s obvious, but I want to re-iterate that it’s important that you put this code into a separate bundle so that it doesn’t interfere with your production code.
Okay, we’re almost ready… Just kidding, we’re done! Sheesh, that’s a lot of code, isn’t it? Was it really worth it? I’ll leave it up you to decide, but from our experience having a single storybook for all visual elements in our hybrid application makes things a lot simpler.
Fixing up the Source
tab
The Source
tab in Lookbook shows the highlighted sources of the current preview (which basically serves as live documentation for the component). However, while it works perfectly fine for our Ruby view components, we can’t say the same for our React components and mailers. Let’s fix this!
Remember the custom :source
property we added while trying to jam all that stuff into Lookbook? Now’s the time to use it. All we need is the following monkey-patch:
# config/initializers/view_component.rb
ActiveSupport.on_load(:view_component) do
if Rails.application.config.lookbook_enabled
Rails.application.config.to_prepare do
class << Lookbook::Lang
LANGUAGES += [
{
name: "tsx",
ext: ".tsx",
label: "TypeScript",
comment: "// %s"
}
]
end
Lookbook::PreviewExample.prepend(Module.new do
def full_template_path(template_path)
@preview.render_args(@name)[:source] || super
end
end)
end
end
end
And here we go:
Yeah, I know: monkey-patching is unreliable, but it’s also cheap! So, until someone decides to implement a similar feature in upstream, you can use this.
In this chapter, we learned how to set up a storybook for your application using the Lookbook gem, and we even adapted it for other things beyond its original design. I think it worked out pretty well! We’re now able to work in isolation, not only on our ViewComponent components, but on other arbitrary things as well.
And yet, I think it begs the question: if previews are such a useful tool for all kinds of things, shouldn’t it be easier to set them up? How about a universal format for previews integrated into Rails? This is a very vague idea, of course, but one worth intensely pondering—so that maybe, one day, it will come to fruition! 😉
Wrapping up
I think we can safely say, that at this point, our ViewComponent setup is definitely supercharged. We’ve covered a lot in this article (and most of it was purely technical), so let’s take a step back and ask ourselves: what have we actually learned? Did we achieve what we wanted? And finally, would we do it again?
I can only speak for myself, but the answer is definitely “yes”. If I were to sum up our own experience with ViewComponent I’d say:
✅ It becomes easier to reason about view code in general
✅ It feels safe to update the view code and rely on the test coverage
✅ Coordination between frontend and backend teams has improved
✅ Frontend stopped being a bottle-neck for apps with relatively simple UIs
There are, of course, other alternatives you could try: Trailblazer cells and nice_partials, but I’m sure you would get the same benefits.
Are there any cons to this approach? Well, you would still need to teach your frontend developers some Ruby which might become an obstacle for some teams. In our case, it wasn’t an issue at all, but you have to consider that Ruby is our native language.
Okay, that’s all I wanted to say, folks! A revolution happened in the frontend world, and I think it’s time for it to happen on backend as well—so hop on board! 🚂
Special thanks to ViewComponent creator Joel Hawksley for kindly taking his time to review this article, and to our principal backend engineer Vladimir Dementyev for most of the ideas here and, of course, for the awesome view_component-contrib
gem.
And one more thing: if you have a problem or project in need, whether it’s an SPA, an MPA, a hybrid application, or anything else really, Evil Martians are here to help! Get in touch!