View components vs partials
A partial's interface is whatever the caller happened to pass. That sentence is the entire case for
ViewComponent, and the reason the product this site sells still has 54 partials and no
app/components directory is that the sentence stays survivable for a long time.
So this is a comparison written from the losing side, which is the only position that makes one worth reading. Below: what the Rails view component gem actually gives you at the version shipping today, where Phlex fits, and the exact condition under which the choice here would change.
What ViewComponent actually hands you
ViewComponent is at 4.15.0, released 2026-08-25 under MIT, maintained by a five person team
(BlakeWilliams, camertron, joelhawksley, Spone, boardfish) in its own GitHub organisation
at ViewComponent/view_component, having moved out of the github org it started in. Version 4
requires Rails 7.1+ and Ruby 3.2+.
The shape is a Ruby class and a sidecar template next to it:
# app/components/message_component.rb
class MessageComponent < ViewComponent::Base
def initialize(name:)
@name = name
end
end
<%# app/components/message_component.html.erb %>
<h1>Hello, <%= @name %>!</h1>
Five things come with that, and they are worth separating because they are not equally hard to get any other way.
A real initializer. initialize(name:) raises ArgumentError at the call site with the
caller's backtrace. Version 4 removed the default initializer from ViewComponent::Base
specifically so you can no longer pass arbitrary arguments into a component that never declared
them.
A unit test that renders. render_inline(ExampleComponent.new(title: "my title")) followed by
assert_selector("span[title='my title']", text: "Hello, World!"). The docs claim that "in the
GitHub codebase, ViewComponent unit tests are over 100x faster than similar controller tests",
which is a comparison against controller tests, not against a view spec, and the difference matters
when you are deciding what you gain.
Previews. A gallery of every state of every component, which version 4 pared back by removing
preview_source and pointing at Lookbook instead.
Slots. renders_one :header and renders_many :posts, filled by the caller with
component.with_header { }. Passing several named blocks into one template is the thing plain
partials are genuinely bad at.
A place for the logic. Predicates and formatting live as private methods on the object rather than as a helper that is globally visible to every template in the application.
Where Phlex sits in the same question
Phlex is the third name in this argument, and it is not a variation on ViewComponent.
Phlex 2.4.1 shipped 2026-02-06, with phlex-rails 2.4.0 from 2026-01-19, both MIT,
both by Joel Drapper and Will Cosgrove, in the yippee-fun organisation on GitHub. The pitch is "a
little Ruby gem for building HTML and SVG view components", and the component defines
view_template which writes the markup in Ruby method calls. No ERB file exists anywhere in that
design.
Release 2.4.0 is the one to read before adopting: it "removed support for inline and sidecar ERB
templates" and added the foundations of a new HTML compiler. Phlex is therefore not a gradual
migration from a view layer full of .html.erb. It is a decision that your templates become Ruby,
which is a much larger commitment than swapping a render call, and the honest reason it does not
appear further down this page.
Either gem is a dependency with a security history you inherit. GHSA-w67g-2h6v-vjgq, published
2026-02-06 at CVSS 7.1, describes "three specific ways to bypass the XSS protection built into
Phlex": splatted user-provided attributes, user-provided tag names passed to tag, and user links
in href. Patched the same day across six maintained branches, from 2.4.1 back to 1.11.1, which is
a project taking its escaping seriously rather than a reason to avoid it.
The view layer this post is written from
The product is a Rails 8.1.3.1 application with 131 ERB templates, 54 of them partials, and no
component gem in the Gemfile. The most reused view element in it is app/views/admin/_icon.html.erb,
rendered from 55 call sites, and the shape of those calls is the whole argument in miniature:
<%= render "admin/icon", key: "rocket", class: "h-5 w-5 shrink-0 text-indigo-600" %>
<%= render "admin/icon", key: local_assigns.fetch(:icon, "dashboard"), class: "h-6 w-6" %>
That file is a case key over 22 string keys emitting SVG paths, with local_assigns.fetch(:class,
"h-5 w-5") for sizing. It works. It has worked across 55 call sites for as long as the admin has
existed, and nobody has wanted a class there.
The interesting file is app/helpers/ui_helper.rb, because the codebase already reached for half of
what ViewComponent sells:
def ui_field(form, attribute, type: :text_field, label: nil, hint: nil, **options)
render "ui/field", form: form, attribute: attribute, type: type,
label: label || attribute.to_s.humanize, hint: hint, options: options
end
A Ruby method with a real keyword signature, defaulting and normalising arguments, standing in front of a template. That is a component with the object left out, arrived at by pressure rather than by choosing a framework. Anybody arguing that partials are enough should look at how often their codebase has done this.
What a partial's interface actually is
local_assigns appears 22 times across 12 templates here, and every one of those is an undeclared
optional argument. The KPI card is representative:
<%# KPI card. Locals: label, value; optional icon, hint, accent. %>
<p class="..."><%= label %></p>
<% if local_assigns[:icon].present? %>
The interface is a comment. Nothing enforces it, nothing fails when a caller passes title: instead
of label: except NameError: undefined local variable at render time, and the only inventory of
what a partial accepts is that first line, written by hand and maintained by hope.
Worse than the undeclared local is the undeclared dependency. app/views/landings/_plan_price.html.erb
ends with a branch on referee_discount_cents.positive?, and referee_discount_cents is not a
local. It is a helper_method on ApplicationController, reachable from that template because
every template can reach everything. Read the partial on its own and you cannot tell what it needs
to render. The ViewComponent docs call this a mystery guest, and they are right that it is one.
Rails partials got an answer to part of this in 7.1, with no gem involved. Strict locals turn the comment into a signature:
<%# locals: (label:, value:, icon: nil, hint: nil, accent: false) -%>
An undeclared local now raises ActionView::Template::Error: unknown local: :unknown_local,
defaults live in the signature, and **attributes is supported for the genuinely open case. The
product does not use this anywhere yet, which is a gap in it rather than a point against the
feature. One line per partial closes most of the interface argument, and closes none of the object
argument: a signature is still not something you can instantiate, pass around, or call a method on.
The icon partial that fails silently
case key in _icon.html.erb has no else. A key that matches nothing falls through 22 when
branches and renders this:
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" aria-hidden="true">
</svg>
Valid HTML, correct dimensions, no content. A request spec that asserts on the page's text passes. A system spec that checks the card rendered passes, because the card did render. The icon is gone and the suite is green, and it stays green until somebody looks at the page.
Typing "referrals" where the file also accepts "gift" is fine, since that branch lists both.
Typing "user_plus" where the file says "user-plus" is the bug, and nothing in Ruby, Rails or the
test suite has any opinion about it. A component with ICONS.fetch(key) in its initializer raises
KeyError on the render that has the typo, which is the same fix available to the partial through
strict locals plus a lookup, and neither version happens by accident.
Previews, and the page this codebase built instead
Previews are the ViewComponent feature people underrate, and the evidence is that a codebase with no
components built one anyway. app/controllers/dev/ui_controller.rb renders every UI kit element
across every theme and variant at /dev/ui, routed only when Rails.env.local? and guarded again
in a before_action with head :not_found. Its own comment calls it the kitchen sink.
Building that page took a controller, a layout, a route guard and a view that has to be updated by
hand when a new element appears. ViewComponent previews are a class per component next to the
component, and a component with no preview is visible as such. The kitchen sink here covers the
themed elements from UiHelper and nothing else, so the icon partial with 55 call sites is absent
from it, which is exactly the omission a per-component convention prevents and a hand-built gallery
invites.
What a component test buys over a view spec
View specs exist, and this codebase has two. spec/views/ai/widgets/_widget.html.erb_spec.rb runs
type: :view, calls render partial: "ai/widgets/widget", locals: { template: template, context: {} },
and uses stub_template to replace the nested partials with sentinel strings so the assertion is
about which partial got chosen rather than what it emitted. Testing a partial in isolation is
available in stock Rails and it is not widely done.
What the component version changes is smaller than the marketing and larger than nothing. The
subject of render_inline(Widget.new(template: template)) is an object, so you can assert on its
methods, construct it with wrong arguments and get an ArgumentError instead of a template error,
and read its dependencies from the initializer rather than from a grep through the ERB. A view spec
names its subject with a string, "ai/widgets/_widget", so renaming that file leaves the spec
loading cleanly and failing only when the example runs, with a missing template error rather than a
missing constant.
The published benchmark on the ViewComponent site is "component: 6498.1 i/s, partial: 2676.5 i/s - 2.50x slower". Treat that as their number on their harness. For a view layer with 131 templates in it, render time was never the reason to switch.
What adopting it costs
Adoption is not the gem line. Adoption is a convention that a team has to hold.
Another layer with its own rules. Components render from templates, templates render from components, and there are now two answers to "where does this markup live". Every new view element becomes a small decision, and small decisions made inconsistently across a year produce a view layer with three conventions in it, which is worse than either one.
Version 4 moved things. Generators are now under the view_component: namespace, generator
options under config.view_component.generate.*, previews config under
config.view_component.previews.*. Anything you read from 2023 about this gem is describing a
different surface.
A new object lifecycle to get wrong. CVE-2026-54497, CVSS 6.8, affects ViewComponent 4.0.0 up
to 4.12.0: a component instance reused across renders keeps the earlier render's state, and the
advisory is specific about what leaks, including "stale helpers, controller, request,
view_flow, format/variant details, and slot child context from an earlier render". The
consequence named is an authorization-aware component showing privileged UI to a lower privileged
user. Partials have no instance to reuse and so cannot have that bug. Fixed in 4.12.0, and the point
is not that the gem is unsafe, it is that an object with a lifecycle is a thing you now own.
A migration that is never finished. Nobody converts 54 partials. You convert six, and for the next two years the codebase has both, and every reader has to know which rule applies where.
The verdict, and the line that flips it
Partials, with strict locals, for an application one person maintains. The argument is not that
components are overhead in the abstract. It is that every benefit in the first section has a
partial-shaped answer that is 80 percent as good and costs one line: strict locals for the
interface, a view spec for the test, a /dev/ui page for the previews, a helper for the logic. The
20 percent you give up is real and it is not worth a second convention at that size. The same
reasoning is why the view layer in Building a SaaS in a weekend is
ERB and stays ERB.
Two things flip it, and neither is aesthetic.
Team size flips it first. A convention is cheap to hold alone and expensive to hold at four people. The moment two developers are adding view elements in the same week, "put it in a class with a named initializer and a preview" is a rule that survives review, and "keep the locals comment accurate" is a rule that does not. If you are hiring into the view layer, adopt before the second person arrives, not after.
View complexity flips it second, and it has a threshold you can name. One element rendered from
three unrelated places, with branching inside it, and more than two optional locals. The pricing
partial above is at that line already: two branches on plan.coupon.active?, a lambda defined in
ERB, a Pricing.billing_default lookup, data attributes feeding a pricing Stimulus controller of
the kind covered in Stimulus controllers in practice, and
a helper method it never declared. That file wants an object. It has not got one because it is the
only file here that wants an object, and one file is not a convention.
What would not flip it: render performance, a preference for Ruby over ERB, or the fact that GitHub, where the gem was written, does it the other way at a scale nothing here resembles.
What this post does not cover
Benchmarks of our own are absent, because a credible one needs a fixed template, a fixed payload and both sides tuned by somebody who wants the other to win.
Also absent: Lookbook, which version 4 now points at for previews and which deserves its own page; the mechanics of migrating an existing partial, since nothing here has been migrated; Phlex against ViewComponent on their own terms, which is a comparison between two object designs rather than between objects and templates; and Slim, Haml and every other template language, since the question on the page was the object, not the syntax.
Comments
No comments yet. Be the first.