Ruby on Rails Module 4 – Views, forms and assets
View components, presenters and Phlex
When a Rails 8.1 partial outgrows its file: presenters, ViewComponent with render tests and previews, Phlex classes, and the escaping each keeps.
What you will learn
- Identify when a partial and its helpers have outgrown their file
- Build a presenter and a ViewComponent with render tests and previews
- Compare plain partials, ViewComponent and Phlex for a team's shared UI
- Recognise the explicit escape hatch of each tool and keep user text escaped
Before you start
What you’ll learn · Before you start
What you will learn
- Identify when a partial and its helpers have outgrown their file
- Build a presenter and a ViewComponent with render tests and previews
- Compare plain partials, ViewComponent and Phlex for a team's shared UI
- Recognise the explicit escape hatch of each tool and keep user text escaped
Before you start
On this page
Kaksha shows a fee badge on 14 screens: paid, pending, overdue, each with a colour and sometimes a student’s name. As a partial, one misspelt local makes the name vanish without an error, and the only way to see every state is to click through the app. The runs on this page take that one badge through four shapes Rails apps use: a partial, a presenter, a ViewComponent with three tests and four previews, and a Phlex class, and then put a name with a script element through each of them.
By the end you can tell when a partial has outgrown its file, move formatting into a presenter, build and test a component, compare it with Phlex, and spot the one line in each tool that would print markup unescaped.
A partial’s locals are a contract nobody checks
Here is the badge as a partial. Its locals are documented in a comment, and the optional name is read with
defined?, the usual way to make a local optional:
<%# app/views/shared/_fee_status_badge.html.erb
A partial that shows a coloured badge for a student's fee status.
Locals: status ("paid", "pending", "overdue"), student_name (optional) %>
<% colors = { "paid" => "green", "pending" => "orange", "overdue" => "red" } %>
<% labels = { "paid" => "Paid", "pending" => "Pending", "overdue" => "Overdue" } %>
<span class="badge badge-<%= colors.fetch(status, "gray") %>">
<%= labels.fetch(status, status.capitalize) %>
<% if defined?(student_name) && student_name %>
for <%= student_name %>
<% end %>
</span> Nothing checks that comment. A caller that writes studentname: instead of student_name: gets a badge without the
name and no error: the run in the ViewComponent section below shows exactly that. The colours and labels are
rebuilt on every render inside the template, and testing the badge means rendering a page around it. None of this
is wrong for a small partial; it becomes a cost when the partial is shared and its states multiply.
| Sign | What it costs |
|---|---|
Optional locals read with defined? |
A misspelt local is silently ignored |
| Logic and data in the template | Every state is checked by rendering a whole page |
| The same partial copied between screens | Copies drift apart, one fix at a time |
| Many states (statuses, roles, empty, error) | Nobody sees them all without a gallery |
The first sign has a fix inside ERB itself: the strict-locals comment from the
ERB lesson, <%# locals: (status:, student_name: nil) -%>, turns a
misspelt local into an error. The other three are what presenters and components address.
Checkpoint
Check: When a partial outgrows its file
A presenter moves formatting into plain Ruby
A presenter: A plain Ruby object that wraps one record and answers the questions a view asks about it, such as a date in local time or a fee in rupees, returning strings so the template only prints them. is a plain Ruby object that wraps a record and answers the questions a view asks: the start date in the reader’s time zone, the fee formatted for its market. The template then only prints what the presenter returns. This one uses Active Support’s own number helper with the locale grouping pattern the Rails API documents:
# A presenter: a plain Ruby object that formats a record for display, so the template only prints.
# It needs no Rails app, no request and no database, which is why it can be checked this quickly.
require "active_support"
require "active_support/number_helper"
require "active_support/core_ext/time"
Batch = Data.define(:name, :starts_at, :fee_paise)
class BatchPresenter
# Indian grouping (12,50,000): the pattern the Rails API gives for number_to_delimited.
LAKH_GROUPS = /(\d+?)(?=(\d\d)+(\d)(?!\d))/
def initialize(batch)
@batch = batch
end
def name = @batch.name
def starts_on
@batch.starts_at.in_time_zone("Asia/Kolkata").strftime("%-d %B %Y, %-I:%M %p IST")
end
def fee
rupees = ActiveSupport::NumberHelper.number_to_delimited(@batch.fee_paise / 100, delimiter_pattern: LAKH_GROUPS)
"₹#{rupees}"
end
end
batches = [
Batch.new("Data Science A1", Time.utc(2031, 5, 15, 3, 30), 125_000_00),
Batch.new("Full-stack web, weekends", Time.utc(2031, 6, 1, 4, 0), 1_250_000_00),
Batch.new("Spoken English trial", Time.utc(2031, 6, 7, 12, 30), 950_00),
]
batches.map { BatchPresenter.new(_1) }.each do |p|
puts "#{p.name.ljust(25)} #{p.starts_on.ljust(27)} #{p.fee}"
end Output
Data Science A1 15 May 2031, 9:00 AM IST ₹1,25,000 Full-stack web, weekends 1 June 2031, 9:30 AM IST ₹12,50,000 Spoken English trial 7 June 2031, 6:00 PM IST ₹950
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby presenter_demo.rb
Three batches, three formats checked at once, with no Rails app, no request and no database: a UTC time became a local start time and each fee got its digits grouped for its market. A presenter returns strings, never markup, so it says nothing about which tag or colour the badge gets; it fits when formatting is the pain and the template is still small.
Rupee fees with lakh grouping
In the en-IN locale the example turns 03:30 UTC into 9:00 AM IST, groups a fee of 12,50,000 rupees in the Indian
places — the last three digits, then pairs — and leaves ₹950 with none. The same presenter serves every institute
whatever its market.
The split between what the value says and what the markup is runs through every tool in this lesson, so it is worth stating once as a rule.
Key idea
A presenter decides what a value says; a component decides what the markup is. Keep formatting rules in plain Ruby that runs without a page, and keep markup where a template or a component owns it.
Checkpoint
Check: Presenters for display formatting
A ViewComponent is a class, a template and its arguments
ViewComponent: A gem that turns a piece of view into a Ruby class with its template beside it: initialize declares the arguments, and the component can be rendered, tested with render_inline and previewed on its own. turns the partial into a Ruby class that inherits from
ViewComponent::Base, with its template in a file beside it. The class’s initialize takes the arguments, and the
methods it defines are what the template calls:
# app/components/fee_status_badge_component.rb
# A ViewComponent that shows a coloured badge for a student's fee status.
class FeeStatusBadgeComponent < ViewComponent::Base
COLORS = { "paid" => "green", "pending" => "orange", "overdue" => "red" }.freeze
LABELS = { "paid" => "Paid", "pending" => "Pending", "overdue" => "Overdue" }.freeze
def initialize(status:, student_name: nil)
@status = status
@student_name = student_name
end
def color
COLORS.fetch(@status, "gray")
end
def label
LABELS.fetch(@status, @status.capitalize)
end
end <span class="badge badge-<%= color %>">
<%= label %>
<% if @student_name %>
for <%= @student_name %>
<% end %>
</span> Step through what one render of it does, from the call to the HTML it hands back:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- new checks the arguments. The page builds FeeStatusBadgeComponent.new(status: "overdue", student_name: "Asha"). initialize takes keyword arguments, so a misspelt name such as studentname raises ArgumentError here, before anything renders.
- render hands over the view. render calls the component's render_in with the page's view context. Helpers stay available through the component's helpers method, but its own data is only what initialize stored.
- Compiled once per class. The first render of the class compiles the template beside it into a method, and later renders reuse it. A missing or broken template fails at this point, for every badge at once.
- before_render, then render?. before_render runs first; then render? decides whether there is anything to show. Both are ordinary methods a component can override, so a badge for no status can render nothing at all.
- The template runs as the object. Inside the template, self is the component: color and label are its own methods, and @student_name is what new stored. Output tags escape exactly as in any ERB template.
- Escaped HTML comes back. The page receives one span with class badge-red and the text Overdue for Asha. In development Rails wraps it in BEGIN and END comments that name the component's template file.
This run renders the badge as the partial, the component and the Phlex class of a later section, then passes the same typo to the partial and to the component, and finally asks for the component’s previews:
require_relative "session"
# The Kaksha app beside this folder: a default `rails new` (Rails 8.1.4) plus view_component 4.16.0 and phlex-rails 2.4.0.
Dir.chdir File.expand_path("../kaksha_vc", __dir__)
Session.run(<<~'SH')
# The same badge as a partial, a ViewComponent and a Phlex class, then one typo:
bin/rails runner ../render_badges.rb
SH Output
# The same badge as a partial, a ViewComponent and a Phlex class, then one typo:
$ bin/rails runner ../render_badges.rb
== The partial: render "shared/fee_status_badge", status: "overdue", student_name: "Asha"
<!-- BEGIN app/views/shared/_fee_status_badge.html.erb
--><span class="badge badge-red">
Overdue
for Asha
</span>
<!-- END app/views/shared/_fee_status_badge.html.erb -->
== The ViewComponent: render FeeStatusBadgeComponent.new(status: "overdue", student_name: "Asha")
<!-- BEGIN app/components/fee_status_badge_component.html.erb
--><span class="badge badge-red">
Overdue
for Asha
</span>
<!-- END app/components/fee_status_badge_component.html.erb -->
== The Phlex class: render FeeStatusBadge.new(status: "overdue", student_name: "Asha")
<span class="badge badge-red">Overdue for Asha</span>
== A typo, studentname instead of student_name, passed to the partial
<!-- BEGIN app/views/shared/_fee_status_badge.html.erb
--><span class="badge badge-red">
Overdue
</span>
<!-- END app/views/shared/_fee_status_badge.html.erb -->
== The same typo passed to the component
ArgumentError: unknown keyword: :studentname
== GET /rails/view_components (the list of previews)
200: /rails/view_components/fee_status_badge_component/overdue, /rails/view_components/fee_status_badge_component/paid, /rails/view_components/fee_status_badge_component/pending, /rails/view_components/fee_status_badge_component/unknown_status
== GET /rails/view_components/fee_status_badge_component/unknown_status
200: <span class="badge badge-gray"> Waived </span>
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby badge_session.rb
The partial and the component produce the same span, each wrapped in development in BEGIN and END comments that
name its file. The typo is where they part: the partial renders “Overdue” with no name, while the component raises
ArgumentError: unknown keyword: :studentname before anything is drawn.
Checkpoint
Check: A ViewComponent class and its template
Tests and previews need no page around the component
Because a component is an object with its own arguments, a test can render it alone. ViewComponent::TestCase
provides render_inline, which returns the HTML as a Nokogiri fragment, and, when Capybara is in the bundle, lets the
test use assert_selector and assert_text on it. One test per state:
require "test_helper"
# test/components/fee_status_badge_component_test.rb: one test per state of the badge. render_inline renders the
# component with no request and no browser; assert_selector and assert_text read the HTML it produced.
class FeeStatusBadgeComponentTest < ViewComponent::TestCase
def test_overdue_is_red_and_names_the_student
render_inline(FeeStatusBadgeComponent.new(status: "overdue", student_name: "Ravi"))
assert_selector "span.badge.badge-red", text: "Overdue"
assert_text "for Ravi"
end
def test_an_unknown_status_is_gray
render_inline(FeeStatusBadgeComponent.new(status: "waived"))
assert_selector "span.badge.badge-gray", text: "Waived"
end
def test_a_name_with_markup_stays_text
render_inline(FeeStatusBadgeComponent.new(status: "paid", student_name: "<b>Asha</b>"))
assert_no_selector "b"
assert_text "for <b>Asha</b>"
end
end require_relative "session"
# The Kaksha app beside this folder: a default `rails new` (Rails 8.1.4) plus view_component 4.16.0 and phlex-rails 2.4.0.
Dir.chdir File.expand_path("../kaksha_vc", __dir__)
Session.run(<<~'SH')
# The component test, run like every other test of the app:
bin/rails test test/components/fee_status_badge_component_test.rb
SH Output
# The component test, run like every other test of the app: $ bin/rails test test/components/fee_status_badge_component_test.rb Running 3 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: ... Finished in …s, … runs/s, … assertions/s. 3 runs, 5 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby component_test_session.rb
Three runs and five assertions passed with no controller, route or browser involved, including the check that a name
with a <b> element stays text. Previews show the same states to people. A class in test/components/previews with
one method per state gives each its own page in development:
# test/components/previews/fee_status_badge_component_preview.rb: every state of the badge, one page each, at
# /rails/view_components/fee_status_badge_component/<method> while the app runs in development.
class FeeStatusBadgeComponentPreview < ViewComponent::Preview
def paid = render(FeeStatusBadgeComponent.new(status: "paid", student_name: "Asha"))
def pending = render(FeeStatusBadgeComponent.new(status: "pending", student_name: "Asha"))
def overdue = render(FeeStatusBadgeComponent.new(status: "overdue", student_name: "Asha"))
def unknown_status = render(FeeStatusBadgeComponent.new(status: "waived"))
end The end of the badge run above asked for them: /rails/view_components listed four pages, one per method, and the
unknown_status page answered 200 with a gray “Waived” badge. Lookbook, a separate gem, builds a browsable gallery
from the same preview classes and also supports Phlex.
Checkpoint
Check: Component tests and previews
Phlex writes the same badge in Ruby
Phlex: A Ruby gem for views written as Ruby classes: a view_template method calls one method per HTML element and escapes text written with plain; phlex-rails lets Rails render such a class. drops the template file: the whole view is a Ruby class whose view_template method
calls one method per HTML element, span(class: …) { … }, and plain for text:
# app/components/fee_status_badge.rb
# A Phlex component that shows a coloured badge for a student's fee status.
class FeeStatusBadge < Phlex::HTML
COLORS = { "paid" => "green", "pending" => "orange", "overdue" => "red" }.freeze
LABELS = { "paid" => "Paid", "pending" => "Pending", "overdue" => "Overdue" }.freeze
def initialize(status:, student_name: nil)
@status = status
@student_name = student_name
end
def view_template
span(class: "badge badge-#{color}") do
plain label
if @student_name
plain " for "
plain @student_name
end
end
end
private
def color = COLORS.fetch(@status, "gray")
def label = LABELS.fetch(@status, @status.capitalize)
end In the badge run, Phlex printed <span class="badge badge-red">Overdue for Asha</span> on one line and without the
annotation comments, because it builds the HTML itself instead of going through an ERB template. The phlex-rails gem
lets render take a Phlex object anywhere a partial or a component would go. The three shapes compare like this:
| Partial | ViewComponent | Phlex | |
|---|---|---|---|
| Files | one .html.erb |
a class and a template | a class |
| Arguments checked | with a strict-locals comment | by initialize |
by initialize |
| Tested alone | no, through a page | render_inline |
calling the object |
| Previews | none | ViewComponent::Preview, Lookbook |
Lookbook |
| Markup written in | ERB | ERB | Ruby methods |
| Extra gem | none | view_component | phlex-rails |
Choosing for a team
Keep partials for markup used in one or two places. Move a piece into ViewComponent when it is shared, has several states, and deserves its own tests and previews; choose Phlex when the team would rather read views as Ruby than as HTML. Convert one component at a time, starting with the partial that has the most states.
Checkpoint
Check: Phlex, and choosing between the three
Escaping still applies, and each tool has one way around it
A component is still a template, so the rules of the ERB lesson hold: output is escaped unless something marks it safe. This run gives the badge a student name that carries a script element and counts the script elements that reached the HTML, first through each tool’s normal output and then through each tool’s explicit opt-out, shown so you can recognise it in code you own:
require_relative "session"
# The Kaksha app beside this folder: a default `rails new` (Rails 8.1.4) plus view_component 4.16.0 and phlex-rails 2.4.0.
Dir.chdir File.expand_path("../kaksha_vc", __dir__)
Session.run(<<~'SH')
# One name with a script element, through each tool and each tool's opt-out:
bin/rails runner ../escaping_badges.rb
SH Output
# One name with a script element, through each tool and each tool's opt-out: $ bin/rails runner ../escaping_badges.rb The default output of each tool: PASS partial, <%= student_name %>: 0 script elements PASS ViewComponent, <%= @student_name %>: 0 script elements PASS Phlex, plain @student_name: 0 script elements The explicit opt-outs (never for user text): FAIL partial, <%= raw student_name %>: 1 script element FAIL ViewComponent, given NAME.html_safe: 1 script element FAIL Phlex, raw(safe(@html)): 1 script element ---- Phlex, raw(@html) with a plain String: Phlex::ArgumentError: You passed an unsafe object to `raw`.
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby escaping_session.rb
All three normal paths printed 0 script elements: <%= %> in the partial and in the component template, and
plain in Phlex, all escape. Each opt-out printed 1: raw in a partial, a string marked html_safe before it reaches
a component, and raw(safe(…)) in Phlex. Phlex is the strictest of the three: raw with a plain string raised
Phlex::ArgumentError, so markup can only get through when someone wrote safe on purpose.
Search for the opt-outs, not for the tags
In a code base you own, search the views, components and helpers for raw, html_safe, <%== and safe(. Each
hit must print markup your app built, never text a person typed; for user text with formatting, use sanitize with
an allowlist, as the ERB lesson shows.
Checkpoint
Check: Escaping in partials, components and Phlex
Practise
Every question below comes from the runs on this page: the presenter’s three batches, the typo that one tool ignored and the other refused, the three tests and four previews, Phlex’s one-line span, and the script elements counted in each tool. Answer one and its explanation opens at once.
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: when would you use a presenter instead of a helper?
When the formatting rule belongs to one kind of record and deserves its own tests. A helper is a module mixed into every view, so its methods take the record as an argument and grow into a long shared list. A presenter wraps one record and answers its display questions, such as a fee formatted for its market or the start time in the reader’s zone, as plain Ruby that runs without a request; the lesson’s presenter formatted three batches with no app running. A helper still suits a small, general piece of markup used everywhere, like a link with an icon. A presenter returns strings, never markup, so the tags stay in the template or component.
Warm-up: what does ViewComponent’s render_inline return, and what can a test do with it?
It renders the component on its own, with no request, controller or browser, and returns the HTML as a Nokogiri fragment. When Capybara is in the bundle, as it is in a new Rails app, ViewComponent::TestCase also lets the test call assert_selector and assert_text on that HTML, so a test can check the span’s class, its text and that a name with markup stayed text. In the lesson’s run, three such tests with five assertions passed, one for each state of the badge.
Two questions in the module bank go further than this page: escaping inside components, and deciding when a team should adopt them. Work out your own answer first, then open the model answer.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
A security audit of an app that uses partials, ViewComponent and Phlex asks how output escaping works in each and where it can be bypassed. Explain it, and describe how you would check the code base.
-
Mid to senior
A Rails app has 40 partials; 3 of them are copied across two apps and have many states. The team asks whether to adopt ViewComponent, Phlex, or neither. What do you ask first, what do you recommend, and how do you roll it out?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What checks a partial's locals when it declares no strict locals?
Nothing. A misspelt local silently vanishes, and each state of the partial can be tested only by rendering a whole page around it.
-
What is a presenter, and what should its methods return?
A plain Ruby object that formats one record for display and can be tested without the app running. It returns strings, never markup.
-
How is a ViewComponent built and tested?
It is a class with its template beside it: initialize checks the arguments, and the template runs with the component as self. render_inline tests and preview classes show each state on its own.
-
How does Phlex write a view?
As a Ruby class whose methods produce the HTML, one call per element. phlex-rails lets render take such a class the way it takes a partial or a component.
-
Do partials, ViewComponents and Phlex views escape their output?
All three escape by default, and each has one explicit way around it, such as raw, html_safe or Phlex's raw(safe(...)). That way must never carry text a person typed.
-
When does a presenter beat a helper?
When a formatting rule belongs to one kind of record and deserves its own tests. A helper is mixed into every view and suits a small piece of markup used everywhere, such as a link with an icon.
Key takeaways
- A partial’s locals are checked by nothing unless it declares strict locals, so a misspelt local silently disappears, and every state of it is tested only through a whole page.
- A presenter is plain Ruby that formats one record for display, testable without an app, and it returns strings, never markup.
- A ViewComponent is a class with its template beside it:
initializechecks the arguments, the template runs with the component asself, andrender_inlinetests and preview classes show every state on its own. - Phlex builds the same HTML from Ruby methods in one class, and phlex-rails lets
rendertake it like a partial or a component. - All three escape output by default, and each has one explicit opt-out (
raw,html_safe,raw(safe(…))) that must never carry text a person typed.
Terms in this lesson
- Phlex
- A Ruby gem for views written as Ruby classes: a view_template method calls one method per HTML element and escapes text written with plain; phlex-rails lets Rails render such a class.
- presenter
- A plain Ruby object that wraps one record and answers the questions a view asks about it, such as a date in local time or a fee in rupees, returning strings so the template only prints them.
- ViewComponent
- A gem that turns a piece of view into a Ruby class with its template beside it: initialize declares the arguments, and the component can be rendered, tested with render_inline and previewed on its own.
References
- Rails Guides: Layouts and Rendering in Rails (8.1) (Ruby on Rails)
- ViewComponent (ViewComponent)
- ViewComponent guide: Testing (ViewComponent)
- ViewComponent guide: Previews (ViewComponent)
- Phlex (Phlex)
- Lookbook (Lookbook)
- ActiveSupport::NumberHelper#number_to_delimited (8.1.4) (Ruby on Rails)
Report a problem with this lesson
Kept only in this browser. Your Learn progress