Skip to content
Branch: master
Go to file
Code

README.md

ViewComponent

ViewComponent is a framework for building view components that are reusable, testable & encapsulated, in Ruby on Rails.

Design philosophy

ViewComponent is designed to integrate as seamlessly as possible with Rails, with the least surprise.

Compatibility

ViewComponent is supported natively in Rails 6.1, and compatible with Rails 5.0+ via an included monkey patch.

ViewComponent is tested for compatibility with combinations of Ruby 2.4+ and Rails 5+.

Installation

In Gemfile, add:

gem "view_component"

In config/application.rb, add:

require "view_component/engine"

Guide

What are components?

ViewComponents are Ruby objects that output HTML. Think of them as an evolution of the presenter pattern, inspired by React.

Components are most effective in cases where view code is reused or benefits from being tested directly.

Why should I use components?

Testing

Unlike traditional Rails views, ViewComponents can be unit-tested. In the GitHub codebase, component unit tests take around 25 milliseconds each, compared to about six seconds for controller tests.

Rails views are typically tested with slow integration tests that also exercise the routing and controller layers in addition to the view. This cost often discourages thorough test coverage.

With ViewComponent, integration tests can be reserved for end-to-end assertions, with permutations and corner cases covered at the unit level.

Data Flow

Traditional Rails views have an implicit interface, making it hard to reason about what information is needed to render, leading to subtle bugs when rendering the same view in different contexts.

ViewComponents use a standard Ruby initializer that clearly defines what is needed to render, making them easier (and safer) to reuse than partials.

Standards

Views often fail basic Ruby code quality standards: long methods, deep conditional nesting, and mystery guests abound.

ViewComponents are Ruby objects, making it easy to follow (and enforce) code quality standards.

Building components

Conventions

Components are subclasses of ViewComponent::Base and live in app/components. It's common practice to create and inherit from an ApplicationComponent that is a subclass of ViewComponent::Base.

Component names end in -Component.

Component module names are plural, as for controllers and jobs: Users::AvatarComponent

Quick start

Use the component generator to create a new ViewComponent.

The generator accepts a component name and a list of arguments:

bin/rails generate component Example title content
      invoke  test_unit
      create  test/components/example_component_test.rb
      create  app/components/example_component.rb
      create  app/components/example_component.html.erb

ViewComponent includes template generators for the erb, haml, and slim template engines and will default to the template engine specified in config.generators.template_engine.

The template engine can also be passed as an option to the generator:

bin/rails generate component Example title content --template-engine slim

Implementation

A ViewComponent is a Ruby file and corresponding template file with the same base name:

app/components/test_component.rb:

class TestComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end

app/components/test_component.html.erb:

<span title="<%= @title %>"><%= content %></span>

Rendered in a view as:

<%= render(TestComponent.new(title: "my title")) do %>
  Hello, World!
<% end %>

Returning:

<span title="my title">Hello, World!</span>

Content Areas

Content passed to a ViewComponent as a block is captured and assigned to the content accessor.

ViewComponents can declare additional content areas. For example:

app/components/modal_component.rb:

class ModalComponent < ViewComponent::Base
  with_content_areas :header, :body
end

app/components/modal_component.html.erb:

<div class="modal">
  <div class="header"><%= header %></div>
  <div class="body"><%= body %></div>
</div>

Rendered in a view as:

<%= render(ModalComponent.new) do |component| %>
  <% component.with(:header) do %>
      Hello Jane
    <% end %>
  <% component.with(:body) do %>
    <p>Have a great day.</p>
  <% end %>
<% end %>

Returning:

<div class="modal">
  <div class="header">Hello Jane</div>
  <div class="body"><p>Have a great day.</p></div>
</div>

Slots (experimental)

Slots are currently under development as a successor to Content Areas. The Slot APIs should be considered unfinished and subject to breaking changes in non-major releases of ViewComponent.

Slots enable multiple blocks of content to be passed to a single ViewComponent.

Slots exist in two forms: normal slots and collection slots.

Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of ViewComponent::Slot, etc.

Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (#rows), which is an Array of ViewComponent::Slot instances.

To learn more about the design of the Slots API, see https://github.com/github/view_component/pull/348.

Defining Slots

Slots are defined by the with_slot macro:

with_slot :header

To define a collection slot, add collection: true:

with_slot :row, collection: true

To define a slot with a custom class, pass class_name:

with_slot :body, class_name: 'BodySlot

Slot classes should be subclasses of ViewComponent::Slot.

Example ViewComponent with Slots

# box_component.rb

class BoxComponent < ViewComponent::Base
  include ViewComponent::Slotable

  with_slot :body, :footer
  with_slot :header, class_name: "Header"
  with_slot :row, collection: true, class_name: "Row"

  class Header < ViewComponent::Slot
    def initialize(class_names: "")
      @class_names = class_names
    end

    def class_names
      "Box-header #{@class_names}"
    end
  end

  class Row < ViewComponent::Slot
    def initialize(theme: :gray)
      @theme = theme
    end

    def theme_class_name
      case @theme
      when :gray
        "Box-row--gray"
      when :hover_gray
        "Box-row--hover-gray"
      when :yellow
        "Box-row--yellow"
      when :blue
        "Box-row--blue"
      when :hover_blue
        "Box-row--hover-blue"
      else
        "Box-row--gray"
      end
    end
  end
end

# box_component.html.erb

<div class="Box">
  <% if header %>
    <div class="<%= header.class_names %>">
      <%= header.content %>
    </div>
  <% end %>
  <% if body %>
    <div class="Box-body">
      <%= body.content %>
    </div>
  <% end %>
  <% if rows.any? %>
    <ul>
      <% rows.each do |row| %>
        <li class="Box-row <%= row.theme_class_name %>">
          <%= row.content %>
        </li>
      <% end %>
    </ul>
  <% end %>
  <% if footer %>
    <div class="Box-footer">
      <%= footer %>
    </div>
  <% end %>
</div>

# index.html.erb

<%= render(BoxComponent.new) do |component| %>
  <% component.slot(:header, class_names: "my-class-name") do %>
    This is my header!
  <% end %>
  <% component.slot(:body) do %>
    This is the body.
  <% end %>
  <% component.slot(:row) do %>
    Row one
  <% end %>
  <% component.slot(:row, theme: :yellow) do %>
    Yellow row
  <% end %>
  <% component.slot(:footer) do %>
    This is the footer.
  <% end %>
<% end %>

Inline Component

ViewComponents can render without a template file, by defining a call method:

app/components/inline_component.rb:

class InlineComponent < ViewComponent::Base
  def call
    if active?
      link_to "Cancel integration", integration_path, method: :delete
    else
      link_to "Integrate now!", integration_path
    end
  end
end

It is also possible to define methods for variants:

class InlineVariantComponent < ViewComponent::Base
  def call_phone
    link_to "Phone", phone_path
  end

  def call
    link_to "Default", default_path
  end
end

Sidecar Assets

ViewComponents supports two options for defining view files.

Sidecar view

The simplest option is to place the view next to the Ruby component:

app/components
├── ...
├── test_component.rb
├── test_component.html.erb
├── ...

Sidecar directory

As an alternative, views and other assets can be placed in a sidecar directory with the same name as the component, which can be useful for organizing views alongside other assets like Javascript and CSS.

app/components
├── ...
├── test_component.rb
├── test_component
|   ├── test_component.css
|   ├── test_component.html.erb
|   └── test_component.js
├── ...

Conditional Rendering

Components can implement a #render? method to be called after initialization to determine if the component should render.

Traditionally, the logic for whether to render a view could go in either the component template:

app/components/confirm_email_component.html.erb

<% if user.requires_confirmation? %>
  <div class="alert">Please confirm your email address.</div>
<% end %>

or the view that renders the component:

app/views/_banners.html.erb

<% if current_user.requires_confirmation? %>
  <%= render(ConfirmEmailComponent.new(user: current_user)) %>
<% end %>

Using the #render? hook simplifies the view:

app/components/confirm_email_component.rb

class ConfirmEmailComponent < ViewComponent::Base
  def initialize(user:)
    @user = user
  end

  def render?
    @user.requires_confirmation?
  end
end

app/components/confirm_email_component.html.erb

<div class="banner">
  Please confirm your email address.
</div>

app/views/_banners.html.erb

<%= render(ConfirmEmailComponent.new(user: current_user)) %>

To assert that a component has not been rendered, use refute_component_rendered from ViewComponent::TestHelpers.

before_render

Components can define a before_render method to be called before a component is rendered, when helpers is able to be used:

app/components/confirm_email_component.rb

class MyComponent < ViewComponent::Base
  def before_render
    @my_icon = helpers.star_icon
  end
end

Rendering collections

Use with_collection to render a ViewComponent with a collection:

app/view/products/index.html.erb

<%= render(ProductComponent.with_collection(@products)) %>

app/components/product_component.rb

class ProductComponent < ViewComponent::Base
  def initialize(product:)
    @product = product
  end
end

By default, the component name is used to define the parameter passed into the component from the collection.

with_collection_parameter

Use with_collection_parameter to change the name of the collection parameter:

app/components/product_component.rb

class ProductComponent < ViewComponent::Base
  with_collection_parameter :item

  def initialize(item:)
    @item = item
  end
end

Additional arguments

Additional arguments besides the collection are passed to each component instance:

app/view/products/index.html.erb

<%= render(ProductComponent.with_collection(@products, notice: "hi")) %>

app/components/product_component.rb

class ProductComponent < ViewComponent::Base
  with_collection_parameter :item

  def initialize(item:, notice:)
    @item = item
    @notice = notice
  end
end

app/components/product_component.html.erb

<li>
  <h2><%= @item.name %></h2>
  <span><%= @notice %></span>
</li>

Collection counter

ViewComponent defines a counter variable matching the parameter name above, followed by _counter. To access the variable, add it to initialize as an argument:

app/components/product_component.rb

class ProductComponent < ViewComponent::Base
  def initialize(product:, product_counter:)
    @product = product
    @counter = product_counter
  end
end

app/components/product_component.html.erb

<li>
  <%= @counter %> <%= @product.name %>
</li>

Using helpers

Helper methods can be used through the helpers proxy:

module IconHelper
  def icon(name)
    tag.i data: { feather: name.to_s.dasherize }
  end
end

class UserComponent < ViewComponent::Base
  def profile_icon
    helpers.icon :user
  end
end

Which can be used with delegate:

class UserComponent < ViewComponent::Base
  delegate :icon, to: :helpers

  def profile_icon
    icon :user
  end
end

Helpers can also be used by including the helper:

class UserComponent < ViewComponent::Base
  include IconHelper

  def profile_icon
    icon :user
  end
end

Testing

Unit test components directly, using the render_inline test helper, asserting against the rendered output.

Capybara matchers are available if the gem is installed:

require "view_component/test_case"

class MyComponentTest < ViewComponent::TestCase
  test "render component" do
    render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }

    assert_selector("span[title='my title']", text: "Hello, World!")
  end
end

In the absence of capybara, assert against the return value of render_inline, which is an instance of Nokogiri::HTML::DocumentFragment:

test "render component" do
  result = render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }

  assert_includes result.css("span[title='my title']").to_html, "Hello, World!"
end

Alternatively, assert against the raw output of the component, which is exposed as rendered_component:

test "render component" do
  render_inline(TestComponent.new(title: "my title")) { "Hello, World!" }

  assert_includes rendered_component, "Hello, World!"
end

To test components that use with_content_areas:

test "renders content_areas template with content " do
  render_inline(ContentAreasComponent.new(footer: "Bye!")) do |component|
    component.with(:title, "Hello!")
    component.with(:body) { "Have a nice day." }
  end

  assert_selector(".title", text: "Hello!")
  assert_selector(".body", text: "Have a nice day.")
  assert_selector(".footer", text: "Bye!")
end

Action Pack Variants

Use the with_variant helper to test specific variants:

test "render component for tablet" do
  with_variant :tablet do
    render_inline(TestComponent.new(title: "my title")) { "Hello, tablets!" }

    assert_selector("span[title='my title']", text: "Hello, tablets!")
  end
end

Previewing Components

ViewComponent::Preview, like ActionMailer::Preview, provides a way to preview components in isolation:

test/components/previews/test_component_preview.rb

class TestComponentPreview < ViewComponent::Preview
  def with_default_title
    render(TestComponent.new(title: "Test component default"))
  end

  def with_long_title
    render(TestComponent.new(title: "This is a really long title to see how the component renders this"))
  end

  def with_content_block
    render(TestComponent.new(title: "This component accepts a block of content")) do
      tag.div do
        content_tag(:span, "Hello")
      end
    end
  end
end

Which generates http://localhost:3000/rails/view_components/test_component/with_default_title, http://localhost:3000/rails/view_components/test_component/with_long_title, and http://localhost:3000/rails/view_components/test_component/with_content_block.

It's also possible to set dynamic values from the params by setting them as arguments:

test/components/previews/test_component_preview.rb

class TestComponentPreview < ViewComponent::Preview
  def with_dynamic_title(title: "Test component default")
    render(TestComponent.new(title: title))
  end
end

Which enables passing in a value with http://localhost:3000/rails/components/test_component/with_dynamic_title?title=Custom+title.

The ViewComponent::Preview base class includes ActionView::Helpers::TagHelper, which provides the tag and content_tag view helper methods.

Previews use the application layout by default, but can use a specific layout with the layout option:

test/components/previews/test_component_preview.rb

class TestComponentPreview < ViewComponent::Preview
  layout "admin"

  ...
end

Preview classes live in test/components/previews, which can be configured using the preview_path option:

config/application.rb

config.view_component.preview_path = "#{Rails.root}/lib/component_previews"

Previews are served from http://localhost:3000/rails/view_components by default. To use a different endpoint, set the preview_route option:

config/application.rb

config.view_component.preview_route = "/previews"

This example will make the previews available from http://localhost:3000/previews.

Configuring TestController

Component tests and previews assume the existence of an ApplicationController class, which be can be configured using the test_controller option:

config/application.rb

config.view_component.test_controller = "BaseController"

Setting up RSpec

To use RSpec, add the following:

spec/rails_helper.rb

require "view_component/test_helpers"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
end

Specs created by the generator have access to test helpers like render_inline.

To use component previews:

config/application.rb

config.view_component.preview_path = "#{Rails.root}/spec/components/previews"

Disabling the render monkey patch (Rails < 6.1)

In order to avoid conflicts between ViewComponent and other gems that also monkey patch the render method, it is possible to configure ViewComponent to not include the render monkey patch:

config.view_component.render_monkey_patch_enabled = false # defaults to true

With the monkey patch disabled, use render_component (or render_component_to_string) instead:

<%= render_component Component.new(message: "bar") %>

Sidecar assets (experimental)

It’s possible to include Javascript and CSS alongside components, sometimes called "sidecar" assets or files.

To use the Webpacker gem to compile sidecar assets located in app/components:

  1. In config/webpacker.yml, add "app/components" to the resolved_paths array (e.g. resolved_paths: ["app/components"]).
  2. In the Webpack entry file (often app/javascript/packs/application.js), add an import statement to a helper file, and in the helper file, import the components' Javascript:
import "../components"

Then, in app/javascript/components.js, add:

function importAll(r) {
  r.keys().forEach(r)
}

importAll(require.context("../components", true, /_component.js$/))

Any file with the _component.js suffix (such as app/components/widget_component.js) will be compiled into the Webpack bundle. If that file itself imports another file, for example app/components/widget_component.css, it will also be compiled and bundled into Webpack's output stylesheet if Webpack is being used for styles.

Encapsulating sidecar assets

Ideally, sidecar Javascript/CSS should not "leak" out of the context of its associated component.

One approach is to use Web Components, which contain all Javascript functionality, internal markup, and styles within the shadow root of the Web Component.

For example:

app/components/comment_component.rb

class CommentComponent < ViewComponent::Base
  def initialize(comment:)
    @comment = comment
  end

  def commenter
    @comment.user
  end

  def commenter_name
    commenter.name
  end

  def avatar
    commenter.avatar_image_url
  end

  def formatted_body
    simple_format(@comment.body)
  end

  private

  attr_reader :comment
end

app/components/comment_component.html.erb

<my-comment comment-id="<%= comment.id %>">
  <time slot="posted" datetime="<%= comment.created_at.iso8601 %>"><%= comment.created_at.strftime("%b %-d") %></time>

  <div slot="avatar"><img src="<%= avatar %>" /></div>

  <div slot="author"><%= commenter_name %></div>

  <div slot="body"><%= formatted_body %></div>
</my-comment>

app/components/comment_component.js

class Comment extends HTMLElement {
  styles() {
    return `
      :host {
        display: block;
      }
      ::slotted(time) {
        float: right;
        font-size: 0.75em;
      }
      .commenter { font-weight: bold; }
      .body { … }
    `
  }

  constructor() {
    super()
    const shadow = this.attachShadow({mode: 'open'});
    shadow.innerHTML = `
      <style>
        ${this.styles()}
      </style>
      <slot name="posted"></slot>
      <div class="commenter">
        <slot name="avatar"></slot> <slot name="author"></slot>
      </div>
      <div class="body">
        <slot name="body"></slot>
      </div>
    `
  }
}
customElements.define('my-comment', Comment)
Stimulus

In Stimulus, create a 1:1 mapping between a Stimulus controller and a component. In order to load in Stimulus controllers from the app/components tree, amend the Stimulus boot code in app/javascript/packs/application.js:

const application = Application.start()
const context = require.context("controllers", true, /.js$/)
const context_components = require.context("../../components", true, /_controller.js$/)
application.load(
  definitionsFromContext(context).concat(
    definitionsFromContext(context_components)
  )
)

This enables the creation of files such as app/components/widget_controller.js, where the controller identifier matches the data-controller attribute in the component's HTML template.

Frequently Asked Questions

Can I use other templating languages besides ERB?

Yes. ViewComponent is tested against ERB, Haml, and Slim, but it should support most Rails template handlers.

Isn't this just like X library?

ViewComponent is far from a novel idea! Popular implementations of view components in Ruby include, but are not limited to:

Resources

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/github/view_component. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct. We recommend reading the contributing guide as well.

Contributors

ViewComponent is built by:

joelhawksley tenderlove jonspalmer juanmanuelramallo vinistock
@joelhawksley @tenderlove @jonspalmer @juanmanuelramallo @vinistock
Denver Seattle Boston Toronto
metade asgerb xronos-i-am dylnclrk kaspermeyer
@metade @asgerb @xronos-i-am @dylnclrk @kaspermeyer
London Copenhagen Russia, Kirov Berkeley, CA Denmark
rdavid1099 kylefox traels rainerborene jcoyne
@rdavid1099 @kylefox @traels @rainerborene @jcoyne
Los Angeles Edmonton Odense, Denmark Brazil Minneapolis
elia cesariouy spdawson rmacklin michaelem
@elia @cesariouy @spdawson @rmacklin @michaelem
Milan United Kingdom Berlin
mellowfish horacio dukex dark-panda smashwilson
@mellowfish @horacio @dukex @dark-panda @smashwilson
Spring Hill, TN Buenos Aires São Paulo Gambrills, MD
blakewilliams seanpdoyle tclem nashby jaredcwhite
@blakewilliams @seanpdoyle @tclem @nashby @jaredcwhite
Boston, MA New York, NY San Francisco, CA Minsk Portland, OR
simonrand fugufish cover franks921 fsateler
@simonrand @fugufish @cover @franks921 @fsateler
Dublin, Ireland Salt Lake City, Utah Barcelona South Africa Chile
maxbeizer franco tbroad-ramsey jensljungblad bbugh
@maxbeizer @franco @tbroad-ramsey @jensljungblad @bbugh
Nashville, TN Switzerland Spring Hill, TN New York, NY Austin, TX
johannesengl
@johannesengl
Berlin, Germany

License

ViewComponent is available as open source under the terms of the MIT License.

You can’t perform that action at this time.