Skip to content

johannesengl/view_component

 
 

Repository files navigation

ViewComponent

A view component framework for Rails.

Current Status: Used in production at GitHub. Because of this, all changes will be thoroughly vetted, which could slow down the process of contributing. We will do our best to actively communicate status of pull requests with any contributors. If you have any substantial changes that you would like to make, it would be great to first open an issue to discuss them with us.

Migration from ActionView::Component

This gem used to be called ActionView::Component. See issue #206 for some background on the name change. Learn more about what changed and how to migrate here.

Roadmap

Support for third-party component frameworks was merged into Rails 6.1.0.alpha in rails/rails#36388 and rails/rails#37919. Our goal with this project is to provide a first-class component framework for this new capability in Rails.

This gem includes a backport of those changes for Rails 5.0.0 through 6.1.0.alpha.

Design philosophy

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

Compatibility

view_component is tested for compatibility with combinations of Ruby 2.4/2.5/2.6/2.7 and Rails 5.0.0/5.2.3/6.0.0/master.

Installation

In Gemfile, add:

gem "view_component"

In config/application.rb, add:

require "view_component/engine"

Guide

What are components?

ViewComponents are Ruby classes that are used to render views. They take data as input and return output-safe HTML. Think of them as an evolution of the presenter/decorator/view model pattern, inspired by React Components.

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

Why should I use components?

Testing

Rails encourages testing views with integration tests. This discourages us from testing views thoroughly, due to the overhead of exercising the routing and controller layers in addition to the view.

For partials, this means being tested for each view they are included in, reducing the benefit of reusing them.

ViewComponents can be unit-tested. In the GitHub codebase, our component unit tests run in around 25 milliseconds, compared to about six seconds for integration tests.

Data Flow

Unlike a method declaration on an object, views do not declare the values they are expected to receive, making it hard to figure out what context is necessary to render them. This often leads to subtle bugs when reusing a view in different contexts.

By clearly defining the context necessary to render a ViewComponent, they're easier 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 code quality standards.

Code Coverage

Many common Ruby code coverage tools cannot properly handle coverage of views, making it difficult to audit how thorough tests are and leading to missing coverage in test suites.

ViewComponent is at least partially compatible with code coverage tools, such as SimpleCov.

Building components

Conventions

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

Component class names end in -Component.

Component module names are plural, as they are for controllers. (Users::AvatarComponent)

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

Quick start

Use the component generator to create a new ViewComponent.

The generator accepts the component name and the list of accepted properties as 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 use the template engine specified in the Rails configuration (config.generators.template_engine) by default.

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>

Which is rendered in a view as:

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

Which returns:

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

Content Areas

A component can declare additional content areas to be rendered in the component. 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>

Which is 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 %>

Which returns:

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

Inline Component

A component can be rendered without any template file as well.

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 render variants inline by creating additional call_ methods.

class InlineVariantComponent < ViewComponent::Base
  def call
    link_to "Default", default_path
  end

  def call_phone
    link_to "Phone", phone_path
  end
end

Using a mixture of templates and inline render methods in a component is supported, however only one should be provided per component (or variant).

Conditional Rendering

Components can implement a #render? method to determine if they should be rendered.

For example, given a component that displays a banner to users who haven't confirmed their email address, the logic for whether to render the banner would need to 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 %>

Instead, the #render? hook expresses this logic in the Ruby class, simplifying 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.

Rendering collections

It's possible to render collections with components:

app/view/products/index.html.erb

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

Where the ProductComponent and associated template might look something like the following. Notice that the constructor must take a product and the name of that parameter matches the name of the component.

app/components/product_component.rb

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

app/components/product_component.html.erb

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

Additionally, extra arguments can be passed to the component and the name of the parameter can be changed:

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>

ViewComponent defines a counter variable matching the parameter name above, followed by _counter. To access this 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 module 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

The helpers. proxy 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 explicitly:

class UserComponent < ViewComponent::Base
  include IconHelper

  def profile_icon
    icon :user
  end
end

Sidecar assets (experimental)

We're experimenting with including 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"]).
  1. 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:

Near the top the entry file, add:

import "../components"

Then add the following to a new file app/javascript/components.js:

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

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

Any file with the _component.js suffix, for example app/components/widget_component.js, will get compiled into the Webpack bundle. If that file itself imports another file, for example app/components/widget_component.css, that will also get 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 will allow you to create files such as app/components/widget_controller.js, where the controller identifier matches the data-controller attribute in the component's HTML template.

Testing

Unit test components directly, using the render_inline test helper. If you have a capybara test dependency, Capybara matchers will be available in your tests:

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']", "Hello, World!")
  end
end

In the absence of capybara, you can make assertions on the render_inline return value, 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

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']", "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

You'll then be able to pass down 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 default to the application layout, but can be overridden:

test/components/previews/test_component_preview.rb

class TestComponentPreview < ViewComponent::Preview
  layout "admin"

  ...
end

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

To use lib/component_previews:

config/application.rb

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

Configuring TestController

Component tests and previews assume the existence of an ApplicationController class, 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"

Frequently Asked Questions

Can I use other templating languages besides ERB?

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

What happened to inline templates?

Inline templates have been removed (for now) due to concerns raised by @soutaro regarding compatibility with the type systems being developed for Ruby 3.

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

view_component 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
@simonrand @fugufish @cover @franks921
Dublin, Ireland Salt Lake City, Utah Barcelona South Africa

License

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

About

View components for Rails

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Ruby 93.2%
  • HTML 5.1%
  • CSS 1.2%
  • Other 0.5%