ได้แรงบันดาลใจจาก React ViewComponents = Ruby Objects สร้าง Markup สำห Render Views ViewComponent = Framework สร้าง Reusable, Testable, Encapsulated View Components ใน Rails ปกติ Reusable Views ใช้ Partials Render ใน Views ต่างๆ แต่ ViewComponent Gem ทำให้ Swap Partials ด้วย View Components ได้ เพราะมี Advantages มากกว่า
When และ Why Use ViewComponents?
View Components Reusable และ Testable ใช้ได้เมื่อ View ถูก Reuse หรือได้ประโยชน์จาก Direct Testing Benefits ตาม Documentation:
- ~10x Faster Than Partials
- Ruby Objects -> Initialize Method ชัดเจน Define Requirements Render View -> เข้าใจง่าย Reuse ได้หลาย Views Enforce Ruby Code Quality Standards -> Reduce Bug Risk
- Unit Test ได้ ตรงข้าม Traditional Views ต้อง Integration Tests (Exercise Routing + Controller + View)
ViewComponent Implementation
ViewComponents = Subclasses ของ ViewComponent::Base อยู่ใน app/components Names End with Component Named สำหรับ What They Render ไม่ใช่ What They Accept Generate ด้วย Generator (ต้อง Add Gem + bundle install ก่อน):
gem "view_component", require: "view_component/engine"
Generator Command:
rails generate component <Component name> <arguments needed for initialization>
Example: Component แสดง Course List บน Learning Platform:
rails g component Course course
Pass course Argument เพราะ Initialize ด้วย Course ที่จะ Display ชื่อ Course เพราะ Render Course

Component + View + Test File สร้างใน app/components
Template Engines: ERB, Haml, Slim Default จาก config.generators.template_engine ระบุ Engine ได้:
rails generate component Course course --template-engine <your template engine>
สร้าง Course Model + Courses:
rails g model Course title:string price:decimal location:string
rails db:migrate
Course.create(title: 'The Art of Learning', price: 125.00, location: 'Denmark')
Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
course_component.rb มี Initialize Method แล้ว:

สร้าง Courses Controller:
rails g controller Courses index
Routes Root: root 'courses#index'
View Creation ใน course_component.html.erb:
<div>
<h2><%= @course.title %></h2>
<h4><%= number_to_currency(@course.price, unit: "€") %></h4>
<h4><%= @course.location %></h4>
</div>
Display Title, Price, Location ด้วย @course จาก Initialize Method คล้าย Controller Variable Available ใน View
Render ใน index.html.erb:
# app/views/courses/index.html.erb
<%= render(CourseComponent.new(course: Course.find(1))) %>
Render Instance ใหม่ Initialize ด้วย Course ที่ต้องการ -> กลายเป็น @course ใน View
Render จาก Controller โดยตรงได้ (Bypass Index View):
class CoursesController < ApplicationController
def index
render(CourseComponent.new(course: Course.find(1)))
end
end
ผลลัพธ์:

Additional Content Pass ให้ Component ได้ 2 วิธี:
<%= render(CourseComponent.new(course: Course.find(1))) do %> container <% end %>
<%= render(CourseComponent.new(course: Course.find(1)).with_content("container")) %>
ใน View Component Include Content ที่ต้องการ Edit Div:
<div class=<%= content %>>

Rendering a Collection (Render Collection)
อยาก Render All Courses? ViewComponent มี with_collection แทน .new Pass Collection เป็น Variable:
CourseComponent.with_collection(Course.all)

with_collection_parameter ระบุชื่อ Parameter ต่างกัน:
class CourseComponent < ViewComponent::Base
with_collection_parameter :item
def initialize(item:)
@item = item
end
end
View ใช้ @item แทน @course
Additional Parameters ต่อ Item ใน Collection ใส่ Buy Me Text:
# app/views/courses/index.html.erb
<%= render(CourseComponent.with_collection(Course.all, notice: "Buy Me")) %>
# app/components/course_component.rb
class CourseComponent < ViewComponent::Base
with_collection_parameter :item
def initialize(item:, notice:)
@item = item
@notice = notice
end
end
Add Paragraph ใน View:
<p><a href='#'> <%= @notice %> </a></p>

Counter Variable Number Items Enable: _counter Suffix ใน Collection Parameter Available ใน Initialize:
def initialize(item:, notice:, item_counter:)
@item = item
@notice = notice
@counter = item_counter
end
View ใส่ Counter ข้าง Title:
<h2><%= @counter %>. <%= @item.title %></h2>
สร้าง Course ที่ 3:
Course.create(title: 'Understanding Databases', price: '100', location: 'Amsterdam')

Conditional Rendering (การ Render แบบมีเงื่อนไข)
render? Hook กำหนดว่า View ควร Render หรือไม่ Implement: 10% Discount สำหรับ Courses Price >= 100€ สร้าง Component:
rails generate component Discount item

Auto Initialize ด้วย Item:

View discount_component.html.erb:
<p class="green"> A 10% discount is available on this course </p>
CSS Class green ใส่สีเขียว Component RB Add render?:
def render?
@item.price >= 100
end
Render ใน Course Component View:
# app/components/course_component.html.erb
<%= render(DiscountComponent.new(item: @item)) %>

Helpers
Traditional Views เรียก Helpers โดยตรงใน Views แต่ ViewComponents เรียกใน Views ไม่ได้ ต้อง Include ใน Component courses_helper.rb (Generated ตอน CoursesController) สร้าง Helper Method นับ Enrollees (Price/4):
module CoursesHelper
def count_enrollees(course)
count = (course.price / 4).round
tag.p "#{count} enrollees so far"
end
end
สร้าง Component เรียก Helper Include Helper แล้ว Call Methods ได้:
# app/components/enrollee_component.rb
class EnrolleeComponent < ViewComponent::Base
include CoursesHelper
def total_enrollees(course)
count_enrollees(course)
end
end
Add ใน Course Component View:
# app/components/course_component.html.erb
<%= EnrolleeComponent.new.total_enrollees(@item) %>
ไม่ใช้ render เพราะไม่มี View Output = Helper Method Result

Helpers ใช้ได้ Icons, Gravatars, Whatever ViewComponent ไม่เปลี่ยน Use ของ Helpers แค่เปลี่ยนวิธีเรียกใน Components
The before_render Method
before_render เรียกก่อน Render Component Add Star Icon ใกล้ Discount Notice Helper Method ดึง Star (star.png ใน app/assets/images):
# app/helpers/courses_helper.rb
def star_icon
image_tag("/assets/star.png", width: "1%")
end
Add before_render ใน Discount Component:
# app/components/discount_component.rb
def before_render
@star_icon = helpers.star_icon
end
Helpers เรียกได้ด้วย helpers.method ใน Component RB View ใส่ Star Icon:
# app/components/discount_component.html.erb
<p class="green">
<%= @star_icon %> A 10% discount is available on this course
</p>

ไม่จำเป็นต้อง Helpers สำหรับ before_render ใช้ Helpers เพื่อ Demo Another Approach
Previews (พรีวิว)
เหมือน Action Mailer ViewComponent Preview Components ได้ Enable ใน config/application.rb:
config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"
Preview Components ใน test/components/previews Preview Component ด้วย Inputs ต่างๆ Preview DiscountComponent:
# test/components/previews/discount_component_preview.rb
class DiscountComponentPreview < ViewComponent::Preview
def with_first_course
render(DiscountComponent.new(item: Course.find(1)))
end
def with_second_course
render(DiscountComponent.new(item: Course.find(2)))
end
end
Visit https://localhost:3000/rails/view_components เห็น Previews Click ดูได้:

Course 1 Render Discount (Price > 100€) Course 2 ไม่ Render (Price < 100€) render? Check Worked Method Names ไว้ให้ Guess เอง :)
Previews Enable/Disable Per Environment show_previews Default Enabled Dev/Test:
# config/environments/test.rb
config.view_component.show_previews = false
JS AND CSS Inclusion
Include JS/CSS พร้อม Components เรียกว่า "Sidecar" Assets/Files Experimental Feature ไม่ Deep Dive ในบทความนี้ Detail: ViewComponent Docs
Templates (เทมเพลต)
Template ของ View Component Define ได้หลายวิธี Simple: View + Component Same Folder (app/components)

Alternative: Subdirectory ชื่อ Component เหมือนกัน app/components/course_component/ มี View + Assets Generator Flag --sidecar:
rails g component Example name --sidecar
Add CSS/JS ใน Component Folder ViewComponents Render โดยไม่ต้อง Template File ด้วย call Method (ดู Slots Section)
Slots (สล็อต)
Multiple Content Blocks Pass ให้ Single ViewComponent = Slots คล้าย has_one/has_many ใน Models Slots Defined By renders_one/renders_many:
renders_one: Slot Render Max Once Per Component:renders_one :headerrenders_many: Slot Render Multiple Times:renders_many :titles
Imagine Page Render Header (Once) + Course Titles (Many) สร้าง ListComponent (Header Once) + TitleComponent (Many Titles):
# app/components/list_component.rb
class ListComponent < ViewComponent::Base
renders_one :header, "HeaderComponent"
renders_many :titles, TitleComponent
class HeaderComponent < ViewComponent::Base
def call
content_tag :h1, content
end
end
end
Header Render Once Titles Many HeaderComponent Defined Inside ListComponent (Possible!) ใช้ call Method Render H1 Tag ไม่ต้อง View File HTML File:
# app/components/list_component.html.erb
<div>
<%= header %> <!-- renders header component -->
<% titles.each do |title| %>
<div>
<%= title %> <!-- renders individual course title -->
</div>
<% end %>
</div>
View ไม่ต้อง Specify Component Name Slots จัดการให้ เรียก header, title
TitleComponent + HTML:
# app/components/title_component.rb
class TitleComponent < ViewComponent::Base
def initialize(title:)
@title = title
end
end
# app/components/title_component.html.erb
<div>
<h3><%= @title %></h3>
</div>
.erb Replace ด้วย ListComponent Render:
# app/views/courses/index.html.erb
<%= render ListComponent.new do |c| %>
<% c.header do %>
<%= link_to "List of Available Courses", root_path %>
<% end %>
<%= c.title(title: "First title") %>
<%= c.title(title: "Second title!") %>
<% end %>

Pass Courses As Collection ใน Slot Pass Array of Objects ที่มี Init Variable ต้องการ Title Argument Extract Titles -> Array of Hashes Render:

Replace Multiple Titles ด้วย c.titles(course_titles):
# app/views/courses/index.html.erb
<% course_titles = Course.all.pluck(:title).map { |title| {title: title} } %>
<% c.titles(course_titles) %>

Slots ทำงานแบบนี้ Render Multiple Components ใน Single ViewComponent รูปแบบอื่นๆ ดู Docs
Testing (การทดสอบ)
Test ViewComponents: Require view_component/test_case ใช้ render_inline Test Helper Assert Against Rendered Output Test DiscountComponent:
require "test_helper"
require "view_component/test_case"
class DiscountComponentTest < ViewComponent::TestCase
def test_render_component
render_inline(DiscountComponent.new(item: "my item"))
end
end
Run rails test test/components/discount_component_test.rb Error:

Test ถูก Component แต่ Prop ไม่ตรง (Item ต้องเป็น Course มี Price Property ไม่ใช่ String) มี render? Check ก่อน Render Fix Props:
def test_render_component
course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
render_inline(DiscountComponent.new(item: course))
end
Pass Add Assertions:
def test_render_component
course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
render_inline(DiscountComponent.new(item: course))
assert_selector 'p[class="green"]'
assert_text "10% discount"
end
Pass Test Component Not Rendered (refute_component_rendered) Course Price < 100€:
def test_component_not_rendered
course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
render_inline(DiscountComponent.new(item: course))
refute_component_rendered
end
Pass Test CourseComponent Render All Nested Components:
require "test_helper"
require "view_component/test_case"
class CourseComponentTest < ViewComponent::TestCase
def test_component_renders_all_children
course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
render_inline(CourseComponent.new(item: course, notice: 'A new test', item_counter: 1))
assert_selector("h2", text: "Organizing your Time")
assert_selector("h4", text: "€155.00")
assert_selector("h4", text: "London")
assert_text("enrollees")
assert_text("discount")
end
end
Pass Test Enrollee + Discount Components Rendering
Slot Component Test (Render Header + Many Titles):
require "test_helper"
require "view_component/test_case"
class ListComponentTest < ViewComponent::TestCase
def test_renders_slots_with_content
render_inline(ListComponent.new) do |component|
component.header { "A Test List" }
component.titles [{title: 'Test title 1'}, {title: 'Test title 2'}]
end
assert_selector("h1", text: "A Test List")
assert_text("Test title 1")
assert_text("Test title 2")
end
end
Pass :)
RSpec Testing
RSpec Preferred Additional Config ใน spec/rails_helper.rb:
# spec/rails_helper.rb
require "view_component/test_helpers"
require "capybara/rspec"
RSpec.configure do |config|
config.include ViewComponent::TestHelpers, type: :component
config.include Capybara::RSpecMatchers, type: :component
end
DiscountComponent Test RSpec:
require "rails_helper"
RSpec.describe DiscountComponent, type: :component do
it "renders the component correctly" do
course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
render_inline(DiscountComponent.new(item: course))
expect(rendered_component).to have_css "p[class='green']", text: "10% discount"
expect(rendered_component).to have_css "img[src*='/assets/star.png']"
end
end
Pass ขึ้น Star Icon เห็นชัด
Conclusion (สรุป)
เขียน ViewComponents หลายตัว ทำ Code Readable มากขึ้น Less Error-Prone จาก Complications ทำให้ Test Views In Isolation ได้ ไม่ได้แค่ During HTTP Requests ViewComponents ใส่ใน Existing Rails App ง่าย เริ่มจาก Views ที่ Reuse มากที่สุด ด้วย Knowledge จนตอนนี้ Should Be Easy หากอยาก Info เพิ่ม Docs หรือ RubyDoc Info