Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

July 31, 2024

•

Last updated July 31, 2024

ViewComponent Crash Course with Ruby on Rails

ViewComponent is a powerful gem that allows you to create reusable, testable, and encapsulated view components in Ruby on Rails applications.

ViewComponent was developed by the GitHub team and has since been adopted by many developers looking for more structure than the default partials and helpers provided by Ruby on Rails. This crash course will review ViewComponent's use case and explore how it might fit your next Rails app.

Installation

rails new view_component_demo -c tailwind -j bun

To begin using ViewComponent, add it to your Rails application's Gemfile:

gem "view_component"
gem "devise"
gem "name_of_person"

Then, run bundle install to install the gem.

We must expand the config's content array to make Tailwind work effectively in our components.

// tailwind.config.js

module.exports = {
  content: [
    "./app/components/**/*.rb", // Add this line
    "./app/components/**/*.html.erb", // Add this line
    "./app/views/**/*.html.erb",
    "./app/helpers/**/*.rb",
    "./app/assets/stylesheets/**/*.css",
    "./app/javascript/**/*.js",
  ],
}

Scaffold the app

Install Devise to get started. We’ll leverage the user model to generate a realistic setup for the demo app.

rails g devise:install
rails g devise User

Be sure to follow all the configuration steps that the installer outputs. I’ve done this many times in other tutorials, so I’ll leave that process out of this one.

I added the name_of_person gem to this app. Follow the docs to install it. We’ll need two new columns for the user model. For that, I’ll generate a new migration and migrate the database. This gem makes handling names super simple with some Ruby code.

rails g migration add_name_to_users first_name:string last_name:string
rails db:migrate

rails g devise:views

Next, update your application_controller.rb file to reflect the following:

class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
    devise_parameter_sanitizer.permit(:account_update, keys:[:name])
  end
end

Add a new name field to extend the signup forms.

<!-- app/views/devise/registrations/edit.html.erb-->

<div class="mb-6">
  <%= f.label :name %><br />
  <%= f.text_field :name %>
</div>

<!-- app/views/devise/registrations/new.html.erb-->
<div class="mb-6">
   <%= f.label :name %><br />
   <%= f.text_field :name %>
</div>

Finally, I updated my layout to account for Devise and some general styles.

<!-- app/views/layouts/application.html.erb-->

<!DOCTYPE html>
<html>
  <head>
    <title>ViewComponentDemo</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
  </head>

  <body>
    <div class="container mx-auto px-4 py-12">
      <nav>
        <%= link_to "Home", root_path, class: "text-lg font-medium underline mr-2" %>
        <% if user_signed_in? %>
          <%= link_to current_user.name, edit_user_registration_path, class: "text-lg font-medium underline" %>
        <% else %>
          <%= link_to "Sign in", new_user_session_path, class: "text-lg font-medium underline" %>
          <%= link_to "Sign up", new_user_registration_path, class: "text-lg font-medium underline" %>
        <% end %>
      </nav>
      <div class="mt-16">
        <%= yield %>
      </div>
    </div>
  </body>
</html>

Modeling the app

We already have a User model, but our Post model is now the focus.

Scaffold a Post model and set your root path to posts#index inside config/routes.rb

rails g scaffold Post title:string content:text user:references

Creating Your First ViewComponent

ViewComponent follows a convention-based approach to creating and organizing components. Here are some fundamental conventions to keep in mind:

  • Components are subclasses of ViewComponent::Base.
  • Components are typically stored in the app/components directory
  • Component names end with Component
  • Module names for components are commonly plural (e.g., Users::AvatarComponent)

To create your first component, you can use the built-in generator:

bin/rails generate component Button label href

This command will generate the following files:

  • app/components/button_component.rb
  • app/components/button_component.html.erb
  • test/components/button_component_test.rb

Implementing the Component

Let's take a look at a basic implementation of a component:

I like to use inheritance and add an application_component.rb file to the mix. All components can then adopt some common patterns as necessary. For instance, view helpers can happen globally on the ApplicationComponent class, inheriting from the ViewComponent::Base component.

# app/components/application_component.rb

# frozen_string_literal: true

class ApplicationComponent < ViewComponent::Base
  include Rails.application.routes.url_helpers
  include Devise::Controllers::Helpers

  def helpers
    ActionController::Base.helpers
  end
end

Now, our button component can inherit from the application component, similar to how Ruby and Rails already works.

# app/components/button_component.rb

class ButtonComponent < ApplicationComponent

  def initialize(label: nil, href:, :theme)
    @label = label
    @href = href
    @theme = theme
  end

  def button_theme
      case @theme
      when :primary
        "btn btn-primary"
      when :secondary
        "btn btn-secondary"
      when :white
        "btn btn-white"
      else
        "btn btn-primary"
    end
  end
end

In this example, I've created a ButtonComponent that accepts a label, href, and theme parameter. The label can be nil, which we’ll address in the template with a conditional statement. I’ll explain why I did this coming up.

You can add the following markup in the button_component.html.erb file. The component gives us access to the instance variables we initialized and any method names we created. This is like view helpers. The link helper accepts a block where we’ll conditionally render the label attribute if passed and otherwise render a content assessor that comes by default with ViewComponent.

<%= link_to @href, class: button_theme do %>
  <%= @label.present? ? @label : content %>
<% end %>

Understanding the content assessor

By default, every ViewComponent class gets a free content assessor. If you have a block for your component, this can be what renders. Let’s look at an example.

<%= render(ButtonComponent.new(href: new_post_path, theme: :primary)) do %>
  <span>Some content</span>
<% end %>

As shown in this example, the component can also pass a block, giving you more options for rendering buttons with icons or additional content. With this addition, our ButtonComponent becomes quite flexible in terms of application, which I find very pleasant.

Add some styles

I added some basic styles to app/assets/stylesheets/application.tailwind.css to improve the UI's look and feel. Of course, this can all be improved greatly.

/* app/assets/stylesheets/application.tailwind.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn {
    @apply font-bold py-2 px-4 rounded inline-flex items-center justify-center text-center w-auto;
  }
  .btn-primary {
    @apply bg-blue-500 text-white hover:bg-blue-700;
  }
  .btn-secondary {
    @apply bg-gray-500 text-white hover:bg-gray-700;
  }
  .btn-white {
    @apply bg-white text-gray-800 border border-gray-400 hover:bg-gray-100;
  }
}

Using the component in views

To use your component in a view, you can render it like this:

<!-- app/views/posts/index.html.erb -->

<p style="color: green"><%= notice %></p>

<div class="flex items-center justify-between flex-wrap mb-6">
  <h1 class="text-3xl font-bold tracking-tighter">Posts</h1>

  <%= render(ButtonComponent.new(label: "New post", href: new_post_path, theme: :primary)) %>
</div>

<div id="posts">
  <% @posts.each do |post| %>
    <%= render post %>
    <p>
      <%= link_to "Show this post", post %>
    </p>
  <% end %>
</div>

This component will output:

<a href="/posts/new" class="btn btn-primary">Get started</a>

Conditional rendering

A nice advantage of ViewComponent is its built-in conditional support. You can encapsulate a typical conditional statement inside the component so it only renders based on the permissions you set. This is great for things like permission-level controls.

For example, say you only wanted to render this button for signed-in users. That could be as simple as the following:

# app/components/button_component.rb

# frozen_string_literal: true

class ButtonComponent < ApplicationComponent
  def initialize(label: nil, href:, theme: :primary)
    @label = label
    @href = href
    @theme = theme
  end

  def button_theme
     case @theme
     when :primary
      "btn btn-primary"
     when :secondary
      "btn btn-secondary"
     when :white
      "btn btn-white"
     else
      "btn btn-primary"
    end
  end

  def render?
    user_signed_in?
  end
end

Note the render? method. This is built into components. In this method, you can pass some form of conditional that will dictate the presence of the component.

Be sure to update your posts_controller with the before_action method that comes with devise

class PostsController < ApplicationController
  before_action :authenticate_user!, only: %i[ new create edit update destroy ]

   def create
      # merge current user with post
    @post = Post.new(post_params.merge(user: current_user))
   end
  # ...
end 

Then remove the user_id form field from app/views/posts/_form.html.erb.


Creating a BlogPostComponent

Generate a new BlogPostComponent. I’ll pass a post option to feed the component data from specific blog posts.

bin/rails generate component BlogPost post

Now, extend the component to include simple methods in the template.

# app/components/blog_post_component.rb

# frozen_string_literal: true

class BlogPostComponent < ApplicationComponent
  def initialize(post:)
    @post = post
  end

  def title
    @post.title
  end

  def date
    @post.created_at.to_formatted_s(:long)
  end

  def author
    @post.user.name || @post.user.email
  end

  def truncated_content
    @post.content.truncate(200, separator: ' ')
  end
end

Here’s a basic template to start with

<!-- app/components/blog_post_component.html.erb -->

<article class="p-6">
  <h2 class="text-xl font-bold tracking-tight"><%= title %></h2>
  <p class="text-sm leading-tight">
    By <%= author %> on <%= date %>
  </p>
  <div class="text-base leading-tight">
    <%= truncated_content %>
  </div>

  <%= render(ButtonComponent.new(label: "Read more", href: post_path(@post), theme: :white)) %>
</article>

Using the BlogPostComponent

Now, let's use this component in a view that displays the list of blog posts:

<!-- app/views/posts/index.html.erb -->

<h1>Latest Blog Posts</h1>

<div class="blog-posts">
  <% @posts.each do |post| %>
    <%= render(BlogPostComponent.new(post:post)) %>
  <% end %>
</div>

Pretty nice! This isn’t a huge leap from helpers and partials, but components have many pros.

Creating a AvatarComponent

Let's create another component for displaying user avatars:

bin/rails generate component Avatar user size

Implement the component:

# app/components/avatar_component.rb

class AvatarComponent < ApplicationComponent
  def initialize(user:, size: :md)
    @user = user
    @size = size
  end

  def avatar_url
    default_avatar_url
  end

  def avatar_class
    case @size
    when :lg
      "size-12 rounded-md"
    when :md
      "size-8 rounded-md"
    when :sm
     "size-6 rounded-md"
    end
  end

  private

  def default_avatar_url
    "https://ui-avatars.com/api/?name=#{@user.name}&size=64"
  end
end

And the template:

<!-- app/components/avatar_component.html.erb -->

<%= image_tag avatar_url, class: avatar_class, alt: @user.name, title: @user.name %>

Using Components Together

Now, let's enhance our BlogPostComponent to use the AvatarComponent:

# app/components/blog_post_component.rb

class BlogPostComponent < ApplicationComponent
  def initialize(post:)
    @post = post
  end

  def title
    @post.title
  end

  def date
    @post.created_at.to_formatted_s(:long)
  end

  def author
    @post.user.name || @post.user.email
  end

  def truncated_content
    @post.content.truncate(200, separator: ' ')
  end
end

Update the BlogPostComponent template:

<!-- app/components/blog_post_component.html.erb -->

<article class="p-6 block">
  <h2 class="text-3xl mb-3 font-bold tracking-tight"><%= title %></h2>
  <div class="flex items-center mb-6">
    <div class="mr-2">
      <%= render(AvatarComponent.new(user: @post.user, size: :sm)) %>
    </div>
    <p><%= author %> on <%= date %></p>
  </div>

  <div class="text-lg leading-relaxed max-w-3xl mb-6">
    <%= truncated_content %>
  </div>

  <%= render(ButtonComponent.new(label: "Read more", href: post_path(@post), theme: :white)) %>
</article>

These examples demonstrate creating and using ViewComponents in a more realistic scenario. The BlogPostComponent and AvatarComponent can be reused across different views, ensuring consistency and making it easier to maintain and update the UI of your Rails application.

Slots

In addition to the content accessor, ViewComponents can accept content through slots. Think of
slots as rendering multiple content blocks, including other components. This is where components get neat!

To demonstrate slots, I’ll create a navigation component with navigation links. To keep things general, we’ll use a container and an item.

rails generate component nav/container
rails generate component nav/item title active href
rails generate component logo path

Note the namespace here for the first two generators. I did this to keep things more organized. This creates a subfolder in app/components, though you must think modularly when rendering. I'll explain more about this later.

  • For the item component, we pass three arguments: title, active, and href.
  • The logo is a global component we’ll use to render our logo. Pretty simple.

In app/components/nav/container_component.rb, we can render many items using the view component's build DSL.

# app/components/nav/container_component.rb

class Nav::ContainerComponent < ApplicationComponent
  renders_one :logo, LogoComponent
  renders_many :items, Nav::ItemComponent
end

For now, I added basic markup to the template.

<div class="bg-slate-50 p-3 m-3 rounded-md flex items-center justify-between">
  <% if logo? %>
    <%= logo %>
  <% end %>

  <div class="flex-1">
    <% items.each do |item| %>
      <%= item %>
    <% end %>
  </div>
</div>

Thanks to the renders_many and renders_one slots, we can access these objects in the template.

Here’s the code for the nav/item_component.rb and nav/item_component.html.erb files

# app/components/nav/item_component.rb

# frozen_string_literal: true

class Nav::ItemComponent < ApplicationComponent
  def initialize(title:, active: false, href:)
    @title = title
    @active = active
    @href = href
  end

  def classes
    "font-medium text-base #{active_class}"
  end

  def active_class
    "text-blue-600 underline" if @active
  end
end
<!-- app/components/nav/item_component.html.erb -->

<%= link_to @href, class: classes do %>
  <%= @title.present? ? @title : content %>
<% end %>

The logo component

# app/components/logo_component.rb

# frozen_string_literal: true

class LogoComponent < ApplicationComponent
  def initialize(path:)
    @path = path
  end
end
<!-- app/components/logo_component.html.erb -->

<%= link_to @path do %>
  <svg class="size-10 stroke-current text-blue-600" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>star</title><g fill="none"><path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.563.563 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.563.563 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>
<% end %>

Putting this together, I updated our main layout to use the new navigation component.

<!DOCTYPE html>
<html>
  <head>
    <title>ViewComponentDemo</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
  </head>

  <body>
    <div class="container mx-auto px-4 py-12">

    <%= render Nav::ContainerComponent.new do |c| %>
        <%= c.with_logo path: root_path %>

        <div class="flex-1">
          <%= c.with_nav title: "Home", href: root_path, active: current_page?(root_path) %>

          <% if user_signed_in? %>
            <%= c.with_nav title: current_user.name, href: edit_user_registration_path, active: current_page?(edit_user_registration_path) %>

          <% else  %>
            <%= c.with_nav title: "Sign in", href: new_user_session_path, active: current_page?(new_user_session_path) %>

            <%= c.with_nav title: "Sign up", href: new_user_registration_path, active: current_page?(new_user_registration_path) %>
          <% end %>
        </div>
      <% end %>

      <div class="mt-16">
        <%= yield %>
      </div>
    </div>
  </body>
</html>

Here, we render the main Nav::ContainerComponent with a block and use the renderables within. This gives us access to the logo and nav slots based on our naming conventions in the Nav::ContainerComponent.

Pretty slick!

Testing the Components

We can’t forget about tests. For the sake of time, I won’t go through every component, but let's write a test for the BlogPostComponent. The test file should already be present when you run the generator.

# test/components/blog_post_component_test.rb

# frozen_string_literal: true

require "test_helper"

class BlogPostComponentTest < ViewComponent::TestCase
  setup do
    @post = posts(:one) 
  end
  test "renders blog post details" do
    render_inline(BlogPostComponent.new(post: @post))

    assert_text "Test Post"
    assert_text "John Doe"
    assert_text "July 29, 2024 22:17"
    assert_text "This is test post content."
    assert_link "Read more"
  end
end

I’m using fixtures for my tests. You’ll want to ensure yours are set up beforehand.

# test/fixtures/users.yml
one:
  first_name: "John"
  last_name: "Doe"
  email: "[email protected]"
two:
  first_name: "Josh"
  last_name: "Doe"
  email: "[email protected]"

# test/fixtures/posts.yml
one:
  title: "Test Post"
  created_at: "July 29, 2024 22:17"
  content: This is test post content.
  user: one

two:
  title: "Test Post 2"
  created_at: "July 29, 2024 22:17"
  content: This is more test post content.
  user: two

This obviously isn’t very thorough, but hopefully, you can see how tests play a nice role in ensuring the functionality of a typical component.

To round out this tutorial, I’ll briefly provide an overview of ViewComponent's benefits and best practices.

Benefits of Using ViewComponent

  1. Encapsulation: Components encapsulate their logic, markup, and styling, making them easier to maintain and reuse.
  2. Testability: ViewComponents can be easily unit-tested, improving your application's overall test coverage.
  3. Performance: ViewComponents are designed to be fast and efficient, with built-in caching mechanisms.
  4. Flexibility: You can use ViewComponents alongside your existing Rails views and partials, allowing for gradual adoption.
  5. Consistency: Creating a library of reusable components ensures a consistent look and feel across your application. Using something like Tailwind paired with view_component can get you very far as your application scales.

Best Practices

  • Name components based on what they render, not what they accept (e.g., AvatarComponent instead of UserComponent.
  • Consider creating an ApplicationComponent that inherits from ViewComponent::Base as a base for all your components.
  • Use the component generator to scaffold new components quickly.
  • Leverage ViewComponent's support for ERB templates, Haml, or Slim for your component's markup.

Adopting ViewComponent in your Rails applications allows you to create more maintainable, testable, and reusable view code. As you become more comfortable with the library, you'll find it easier to break down complex UIs into manageable, encapsulated components that can be composed to create rich user interfaces. I’m planning on incorporating this into Rails UI (my other project) over time. Writing and recording this crash course has inspired me to push forward with it.

Link this article
Est. reading time: 14 minutes
Stats: 245 views

Categories

Collection

Part of the Ruby on Rails collection

Products and courses