LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 3 views

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:

The same KPI card stating its interface three ways, compared on three axes: a plain partial declares it in a hand-written comment, strict locals declare it in a signature, and a ViewComponent declares it in a real initializer. On a typo the partial raises NameError at render time in the template, strict locals raise unknown local at render time, and the component raises ArgumentError at the call site with the caller's backtrace. Read on its own the partial hides its mystery guest referee_discount_cents, strict locals list the locals but not the helpers, and the component's dependencies are read from the initializer.

<%# 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.

#rails #views

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.