In recent years, the Rails ecosystem improved by leaps and bounds and is catching up with the evolutions that developers use and love in JavaScript frameworks.
Under the code name NEW MAGIC (now known as Hotwire), the Basecamp team released Turbo and Stimulus in 2020, adding powerful capabilities such as near-instant navigation, first-party WebSocket support, lazy-loading parts of your application, and many others.
However, the Rails development I’m most excited about is the ability to build your own component library, powered by View Component and Storybook.
A component library is a set of components (buttons, alerts, domain-specific widgets, etc.) that can be reused throughout the app, reducing the need for duplication and improving the consistency of our UX and codebase.
This article will explain how to create your own component library of View Components and deploy it with Storybook, enabling all your team members to try, tweak and audit them in isolation.
Last fall, I stumbled upon a great RailsConf talk called Encapsulating Views by Joel Hawksley, introducing the View Components gem—GitHub’s take on making React-like components in Rails a reality.
View Components make it easy to build reusable, testable & encapsulated components in your Ruby on Rails app. We will not dive deeply into View Components in this post, but if you’re not familiar with them, I highly recommend taking a look at the first few paragraphs of the docs before continuing—they do a great job at explaining their benefits and use cases.
At Orbit, we are slowly building a list of View Components that we reuse across the app—buttons, selects, dropdowns,… However, as the list grows, it’s becoming harder for the whole team (engineering, design, and product) to know what is already available and reusable. We needed a way to organize this library.
A common (and honestly amazing) tool for such component libraries in JS-based apps is called Storybook. Storybook is an interface that provides an interactive playground for each component, alongside its documentation and other niceties. Here are some examples of Storybooks:
Storybook used to be only compatible with Single Page Apps created with JavaScript frameworks: React, Vue, Angular, and many others. Fortunately for us, the recent V6 release of Storybook introduced the @storybook/server package, which allows for any HTML snippet to be used as a component in Storybook. Theoretically, this allows for a Rails backend to render the components for Storybook. But how does that work in practice?
For the purpose of this article, we’re going to work off of a fresh Rails project and work our way through installing the required gems, create our first ViewComponent, display it in Storybook, and deploy it alongside our app. The source code for this Rails project is available on GitHub: https://github.com/phacks/rails-view-components-storybook.
If you’d rather jump into a particular section (as you might already be familiar with some of the concepts we’ll cover), here’s the outline for the rest of the article:
Let’s create a new Rails project by following the steps listed in Section 3.1 in the Rails Getting Started guide, then run
{% c-block language="ruby" %}
rails new rails-view-components-storybook
cd rails-view-components-storybook
rails webpacker:install
# in one terminal window
bin/webpack-dev-server
# in another terminal window
rails server
{% c-block-end %}
That should get a Rails project up and running at http://localhost:3000
We’re going to add a static page to our Rails app which will serve as a kitchen sink to view and interact with our upcoming View Components. To do so, we can create or update the following files:
app/controllers/pages_controller.rb
{% c-block language="ruby" %}
class PagesController < ApplicationController
def show
render template: "pages/#{params[:page]}"
end
end
{% c-block-end %}
config/routes.rb
{% c-block language="ruby" %}
Rails.application.routes.draw do
get "/pages/:page" => "pages#show"
end
{% c-block-end %}
app/views/pages/kitchen-sink.html.erb
{% c-block language="html" %}
<article class="prose m-24">
<h1>ViewComponents kitchen sink</h1>
<p>This page will demo our ViewComponents</p>
</article>
{% c-block-end %}
We should now see that new page over at http://localhost:3000/pages/kitchen-sink. Great!
In order to add styles to our upcoming components, we’re going to add TailwindCSS (a utility-first CSS framework). Please note that it is not a requirement for either Storybook or ViewComponents—we only install it here for conciseness and convenience in styling our component. You do not need to have any prior knowledge of Tailwind to continue reading this article.
Replace the contents of app/views/layouts/application.html.erb with:
{% c-block language="html" %}
<!DOCTYPE html>
<html>
<head>
<title>RailsViewComponentsStorybook</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/base.min.css" />
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/components.min.css" />
<link
rel="stylesheet"
href="https://unpkg.com/@tailwindcss/typography@0.2.x/dist/typography.min.css"
/>
<link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/utilities.min.css" />
</head>
<body>
<%= yield %>
</body>
</html>
{% c-block-end %}
Note: although using unpkg is the simplest way to install TailwindCSS, it is not recommended to do so for production applications as it will cause performance issues. Should you want to install TailwindCSS for a production application, I’d recommend following their instructions.
Buttons are one of the most commonly used UI components throughout web applications, and are usually one of the first that comes to mind when the time comes to create a component library. Let’s build a Button ViewComponent!
In the Gemfile, add
{% c-block language="ruby" %}
gem "view_component", require: "view_component/engine"
{% c-block-end %}
Then run bundle install and restart the Rails server to finish installing the ViewComponents gem.
We want our button to have different styles depending on how we’re planning to use it: primary, outline and danger. Let’s create a new ViewComponent called Button with a type property:
{% c-block language="ruby" %}
# in another terminal window
bin/rails generate component Button type --preview
{% c-block-end %}
This command generates four files:
We’re not going to cover ViewComponents testing in this post; if you’re curious, the relevant docs page is a great resource to get started.
Let’s define our component template so that it outputs a styled <button> rendering the content passed into the ViewComponent:
app/components/button_component.html.erb
{% c-block language="ruby" %}
<button class="<%= classes %>">
<%= content %>
</button>
{% c-block-end %}
Then, we can add the logic to apply different classes for the different types:
app/components/button_component.rb
{% c-block language="ruby" %}
# frozen_string_literal: true
class ButtonComponent < ViewComponent::Base
attr_accessor :type
PRIMARY_CLASSES = %w[
disabled:bg-purple-300
focus:bg-purple-600
hover:bg-purple-600
bg-purple-500
text-white
].freeze
OUTLINE_CLASSES = %w[
hover:bg-gray-200
focus:bg-gray-200
disabled:bg-gray-100
bg-white
border
border-purple-600
text-purple-600
].freeze
DANGER_CLASSES = %w[
hover:bg-red-600
focus:bg-red-600
disabled:bg-red-300
bg-red-500
text-white
].freeze
BASE_CLASSES = %w[
cursor-pointer
rounded
transition
duration-200
text-center
p-4
whitespace-nowrap
font-bold
].freeze
BUTTON_TYPE_MAPPINGS = {
primary: PRIMARY_CLASSES,
danger: DANGER_CLASSES,
outline: OUTLINE_CLASSES
}.freeze
def initialize(type: :primary)
@type = type
end
def classes
(BUTTON_TYPE_MAPPINGS[@type] + BASE_CLASSES).join(' ')
end
end
{% c-block-end %}
And finally we can instantiate all three types of buttons in our kitchen sink page:
app/views/pages/kitchen-sink.html.erb
{% c-block language="ruby" %}
<article class="prose m-24">
<h1>ViewComponents kitchen sink</h1>
<p>This page will demo our ViewComponents</p>
<h2>ButtonComponent</h2>
<h3>Primary</h3>
<%= render(ButtonComponent.new(type: :primary)) do %>
Submit
<% end %>
<h3>Outline</h3>
<%= render(ButtonComponent.new(type: :outline)) do %>
Cancel
<% end %>
<h3>Danger</h3>
<%= render(ButtonComponent.new(type: :danger)) do %>
Delete
<% end %>
</article>
{% c-block-end %}
We have our ButtonComponent all ready for others to use!
ViewComponents come ready with a handy feature: component previews. They allow us to get a URL in which to view and interact with our ViewComponent in isolation.
We can see the preview for our ButtonComponent at the following URL: http://localhost:3000/rails/view_components/button_component/default
The default preview instantiates the ButtonComponent without any parameters, which explains why we see the :primary button type and no content. We can update the preview file to teach it about the different variants:
test/components/previews/button_component_preview.rb
{% c-block language="ruby" %}
class ButtonComponentPreview < ViewComponent::Preview
def default(type: :primary)
type = type.to_sym if type
render(ButtonComponent.new(type: type)) { 'Button' }
end
end
{% c-block-end %}
We can then control our component through the type and content query params. For example, http://localhost:3000/rails/view_components/button_component/default?type=danger will render a red button, and http://localhost:3000/rails/view_components/button_component/default?type=outline will render an outlined one.
Let’s also add individual stories for each button state. That makes it easy to reason about as the component grows in supported states because it reduces ambiguity about which props are intended to be used together:
test/components/previews/button_component_preview.rb
{% c-block language="ruby" %}
class ButtonComponentPreview < ViewComponent::Preview
def default(type: :primary)
type = type.to_sym if type
render(ButtonComponent.new(type: type)) { 'Button' }
end
def primary
render(ButtonComponent.new(type: :primary)) { 'Submit' }
end
def outline
render(ButtonComponent.new(type: :outline)) { 'Cancel' }
end
def danger
render(ButtonComponent.new(type: :danger)) { 'Delete' }
end
end
{% c-block-end %}
We can check that these previews work as intended by visiting the following URLs:
This mechanism will be leveraged in the next section to control our ViewComponent through Storybook controls. It’s time to add Storybook to our project!
The view_component_storybook gem is the bridge between Ruby on Rails land and Storybook. It gives us a Ruby DSL in which we can write stories (Storybook’s main concept: think a specific state of a UI component), that will then be translated in Storybook parlance. It also takes care of gluing together the ViewComponents previews and Storybook’s API.
Important note: the instructions below differ from the view_component_storybook official docs. This version allows for easier deployment of the Storybook to a public URL, which will be discussed in Deploying our Storybook alongside our app. If you don’t plan on deploying your Storybook, you might want to follow the official docs instead.
First, in our console, we can install the following Storybook packages. This is required to get the Storybook interface up and running:
{% c-block language="ruby" %}
yarn add @storybook/server @storybook/addon-controls --dev
{% c-block-end %}
Then, let’s add the view_component_storybook gem to your Gemfile and declare it in our application:
Gemfile
{% c-block language="ruby" %}
gem "view_component_storybook"
{% c-block-end %}
config/application.rb
{% c-block language="ruby" %}
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module RailsViewComponentsStorybook
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.1
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
require "view_component/storybook/engine"
# Enable ViewComponents previews
config.view_component.show_previews = true
end
end
{% c-block-end %}
We can then create the Storybook configuration files in a new .storybook folder located at the root of the project:
.storybook/main.js
{% c-block language="ruby" %}
module.exports = {
stories: ["../test/components/**/*.stories.json"],
addons: ["@storybook/addon-controls"],
};
{% c-block-end %}
.storybook/preview.js
{% c-block-end %}
export const parameters = {
server: {
url: `${location.protocol}${location.hostname}${
location.port !== "" ? ":3000" : ""
}/rails/view_components`,
},
};
{% c-block-end %}
We’ll wrap up the setup by adding shortcuts in package.json to build the Storybook files:
package.json
{% c-block language="json" %}
{
"name": "rails-view-components-storybook",
// ...
"scripts": {
"storybook:build": "build-storybook -o public/_storybook"
}
}
{% c-block-end %}
We can then restart the Rails server to account for the new gem.
Phew! That was quite a lot of configuration—fortunately, we only have to set everything up this one time. We should now be all up and running. Let’s check that Storybook is properly set up by running yarn storybook:build and visiting http://localhost:3000/_storybook/index.html
While we have Storybook up and running, you might notice that our ButtonComponent is nowhere to be found. That’s totally normal: we need to write a story for it first.
In Storybook, a story represents the state of a UI component. A component can have one or many stories, usually depending on its complexity: one can imagine a Select component with a few options, or a lot, or not at all. In our case, we’ll create a story for each state of our Button component (:primary, :outline and :danger) and another, default one that will allow us to control the type interactively.
A story can also define one or more controls: those will define the interactive bits of our components. In our default story, we can define a control for the button type. That control will be a select as we want the Storybook visitor to be able to select the type between the three available options. There are a lot more controls available in the view_component_storybook gem, and the full list is available here.
Let’s create a story for our component using the Story DSL of view_component_storybook:
test/components/stories/button_component_stories.rb
{% c-block language="ruby" %}
class ButtonComponentStories < ViewComponent::Storybook::Stories
story(:default) do
controls do
select(:type, %w[primary outline danger], 'primary')
end
end
story(:primary) {}
story(:outline) {}
story(:danger) {}
end
{% c-block-end %}
We can now ask view_component_storybook to convert that Ruby story to a JSON one, which will then automatically get picked up by Storybook:
{% c-block language="ruby" %}
rake view_component_storybook:write_stories_json
{% c-block-end %}
This generates a new button_component.stories.json file alongside the Ruby story that is compatible with Storybook’s API.
Let’s re-build our Storybook instance to see that story in action:
{% c-block language="ruby" %}
yarn storybook:build
{% c-block-end %}
Now, http://localhost:3000/_storybook/index.html should display our Button in the different state, and the associated controls to interactively change its type for the default story.
Congratulations—we have created our first component in our Rails component library!
A component library works best when the whole team—engineers, designers, product folks—can see which components are available, know which variants and customization options are available, and get a sense of how they can be used by using them directly. A publicly accessible URL is a great way to achieve this, as one can then include a link to a particular component variant when discussing an upcoming feature.
At its core, Storybook is a React app—which means that deployment is a matter of hosting a static website. We aimed for a simple setup for our Storybook, and found one right under our nose: Rails is very capable of hosting static webpages itself!
As you might have noticed, you had to run yarn storybook:build for our story to appear in Storybook. We defined that command in package.json as followed:
{% c-block language="ruby" %}
"storybook:build": "build-storybook -o public/_storybook"
{% c-block-end %}
What this command does under the hood is compile all the files from Storybook and storing them under the public/_storybook directory of our Rails application. Because files under public are accessible publicly in Rails, this results in the Storybook being accessible at the URL <YOUR_APP_ROOT_URL>/_storybook/index.html. That’s the reason why we were able to see our local Storybook instance at http://localhost:3000/_storybook/index.html!
The main advantage of that solution is that deploying Storybook is now completely transparent and integrated into your Rails deployment pipeline. When adding a new component, updating a story, or installing a Storybook Addon, we only need to run yarn storybook:build and commit the resulting files for those updates to be deployed alongside the rest of our Rails app.
To illustrate that point, let’s take the example of the Rails app we’ve been using for this article. You can visit the Rails app itself at https://rails-view-components-storyboo.herokuapp.com/pages/kitchen-sink, and the Storybook we just built at https://rails-view-components-storyboo.herokuapp.com/_storybook/index.html. Ain’t that cool?
In this article, we saw how we can leverage the view_component and view_component_storybook gems to build a Storybook component library in a Rails app.
The setup described here is admittedly simple, but our team at Orbit is happily using and refining it, and it helps us iterate faster on UI components. We also rely on Storybook Addons for automatic accessibility audits, components documentation, inline Figma designs, and more. If you’re curious about our setup or would like to discuss this article, feel free to reach out on Twitter—I’d be happy to chat!
The intersection of Rails, ViewComponents, and Storybook is an exciting, burgeoning, and fast-evolving space. If you’re curious, you can learn more about how GitHub uses ViewComponents for its Primer design system in this Ruby Blend episode (podcast), take a deep dive to understand how they are implemented in this RailsConf 2020 conference talk (video), or get inspired by the components used by Gov.UK (docs).
P.S. We're hiring! Check out our careers page and read our key values.