Skip to content

View Components

Dany Marcoux edited this page Feb 23, 2023 · 34 revisions

Problem

Any view code (helpers, partials, etc...) with questionable code quality. It is perhaps complex or huge. It's unclear what data it needs since it doesn't have a clear interface. On top of this, it is probably barely tested and even if it is, it's only through slow integration tests of pages in which it is used.

Solution

Replace the view code by a view component located under src/api/app/components. View components are plain old Ruby objects (PORO). It makes them reusable, easy to test through unit tests, encapsulated and they have a clear interface defined by their initialize method. Replacing pretty much any view code by a view component is beneficial, unless it is super simple without any logic and used in a single view. Using a view component won't be very helpful in this case.

Details

Why We Chose The view_component Gem

There are many view component implementations in Ruby, but we decided to use the view_component gem since it integrates nicely with Rails and has increasing support throughout the Rails community. Its open-source development is also backed by GitHub.

Conventions

  • All view components must inherit from ApplicationComponent. This parent class has methods to prevent potential errors in common scenarios when using view components.
  • View component names end with Component, therefore their file names end with _component.rb.
  • Name view components after what they render, not what they accept. (Example: AvatarComponent instead of UserComponent if the view component is rendering the avatar of a user.)

How to Write View Component Specs

Specs of view components are located under src/api/app/spec/components. They are typical unit tests with a few extra methods and Capybara matchers like have_text, have_css (and much more) are available.

Specs can be split in two common scenarios:

  1. A single expectation

In the code below, the view component is rendered with render_inline(...). We use the have_text matcher from Capybara to test the rendered component.

require 'rails_helper'

RSpec.describe ExampleComponent, type: :component do
  context 'for anonymous user' do
    it do
      expect(render_inline(described_class.new(title: 'Everything is fine'))).to have_text('Everything is fine')
    end
  end
end
  1. Multiple expectations

The view component is rendered with render_inline(...) once in a before block to avoid wasting time by rendering it multiple times. The rendered component is stored in rendered_content, a method provided by the view_component gem. Use Capybara matchers like have_text for your expectations. Setting User.session = create(:admin_user) in a before block allows you to test how the view component renders for various users based on their permissions.

require 'rails_helper'

RSpec.describe ExampleComponent, type: :component do
  context 'for admin user' do
    before do
      User.session = create(:admin_user)
      render_inline(described_class.new(title: 'Everything is fine for an admin'))
    end

    it do
      expect(rendered_content).to have_text('Everything is fine for an admin')
    end

    it do
      expect(rendered_content).to have_text('Delete this example?')
    end
  end
end

Create Previews for Your View Components

Previews of view components are located under src/api/app/spec/components/previews. They are accessible at https://$HOST:$PORT/rails/view_components/$COMPONENT_NAME/$METHOD_NAME. Their names end with ComponentPreview and their file names end with _component_preview.rb. We have a custom RuboCop cop to enforce the presence of previews for every view component.

Previews are enabled by default in development and test environments.

class ExampleComponentPreview < ViewComponent::Preview
  # Accessible at https://my_app.com/rails/view_components/example_component/with_a_title
  def with_a_title
    render(ExampleComponent.new(title: "This is my example"))
  end
end

How to Use Custom Helpers in View Components

In view components, custom helpers can be used through the helpers proxy. So if you have a custom helper named user_icon, use it inside a view component with helpers.user_icon. Built-in helpers like link_to shouldn't be prefixed by helpers.

Authorization inside View Components with Pundit

To authorize users inside view components or their Haml template, use the policy method provided by ApplicationComponent. It is exactly the same as the policy method you already know from Pundit. Beside the argument provided to the policy method, nothing should change when migrating authorization code to a view component.

For example, to check in a view component if the current user can create the package my_package, write policy(my_package).create?.

Avoid Global State

The more a view component is dependent on global state (such as request parameters or the current URL), the less likely it’s to be reusable. Avoid implicit coupling to global state, instead passing it into the component explicitly. Thorough unit testing is a good way to ensure decoupling from global state. We have a custom RuboCop cop to enforce this.

# good
class MyComponent < ViewComponent::Base
  def initialize(name:, user:)
    @name = name
    @user = user
  end
end

# bad
class MyComponent < ViewComponent::Base
  def initialize
    @name = params[:name]
    @user = User.session
  end
end

Most View Component Instance Methods Can Be Private

Most view component instance methods can be private, as they will still be available in the component template:

# good
class MyComponent < ViewComponent::Base
  def initialize; end

  private

  def method_used_in_template; end
end

# bad
class MyComponent < ViewComponent::Base
  def initialize; end

  def method_used_in_template; end
end

Avoid Database Queries

Avoid executing database queries in view components. Be especially careful for view components which are rendered as lists. Any data needed by view components should instead be passed when they are instantiated, so in their .new call.

Conditional Rendering

View components can implement a #render? method which hooks into the view component initialization to determine if it should render. This simplifies the view and avoids code duplication as traditionally, this check is done in the view.

OBS Components Details

Some components implemented in OBS might need some clarification. Here you have links to the details of those components.

Links

Frequently Asked Questions

Why view_component Instead of a Frontend Framework like React/Vue?

React and Vue are 2 JavaScript frameworks which also implement view components, they call them components. The main difference with the view_component gem is that this is a client-side approach. For our needs, using a JavaScript framework would be pulling a lot of dependencies and imply a lot of changes only to support view components. Most of the JavaScript we write tends to be boilerplate code, so a simpler framework like Stimulus would be a much better choice for OBS. This is why we went with the view_component gem and a server-side approach.

Should We Use View Components For New Features?

Yes and not only for new features! View components are easy to test through unit tests, encapsulated and they have a clear interface defined by their initialize method. However, any view code which doesn't need to be tested doesn't benefit much from being a view component.

Should We Migrate Existing View Code to View Components?

Yes! However, a super simple code without any logic doesn't benefit much from being migrated to a view component since in this case, tests aren't needed and the clear interface of a view component through its initialize method isn't helpful if it's empty.

Clone this wiki locally