Cells and view objects: components in Rails
Partials plus helpers plus instance variables make Rails views hard to reuse and impossible to test. A deep-dive on view components in Rails with Cells — encapsulated, testable units of UI, and why the component idea is coming to the server.
The front-end world reorganised itself around components — self-contained units of UI that
own their markup, logic, and state — and it was a genuine improvement. Meanwhile Rails views
have stayed much as they were: partials that pull data from shared instance variables, helper
methods scattered in modules, and logic creeping into templates. That model creaks at scale —
views become hard to reuse, impossible to test in isolation, and tangled with the controllers
that set up their @variables. View components bring the component idea to the server, and
in 2018 the established way to do it in Rails is the Cells gem. Here is the case for them.
The problem with the default view layer
Consider a “product card” shown on the home page, the category page, and search results. In vanilla Rails it is a partial, and it works — until you look closely:
- It depends on ambient state. The partial reaches for
@productor expects locals passed just so; its dependencies are implicit, set up by whichever controller rendered it. Reuse it somewhere new and you must remember to recreate that setup. - Its logic has no home. The card needs a formatted price, a badge, a truncated description — presentation logic that ends up in helpers (global, untethered) or inline in the template (untestable).
- You cannot test it in isolation. To test how a product card renders, you essentially have to render a controller action and parse HTML, because the partial is not a thing you can instantiate — it is a fragment that only exists mid-request.
These are the same complaints that drove the front end to components: the unit of UI is not a real, self-contained, testable object. It is a fragment dependent on its surroundings.
What a Cell is
A Cell is a view component as a real object: a Ruby class that encapsulates a piece of UI — its own template, its own logic, and an explicit interface for the data it needs. You instantiate it with its inputs, and it renders itself:
# app/cells/product_cell.rb
class ProductCell < Cell::ViewModel
def show
render # renders app/cells/product/show.haml
end
private
def price
number_to_currency(model.price)
end
def badge
model.eco? ? "Eco" : nil
end
end
-# app/cells/product/show.haml
.card
%h3= model.name
%span.price= price
- if badge
%span.badge= badge
And you render it explicitly, passing exactly what it needs:
<%= cell(:product, @product) %>
<%# in a collection: %>
<%= cell(:product, collection: @products) %>
The shift is fundamental: the card is now a class, with an explicit input (model), its logic
as methods on that class, and its template owned by it. There is no ambient @product; you hand
it the product. It is a self-contained component, not a fragment.
Why this is better
The payoffs mirror exactly why components won on the front end:
-
Encapsulation. Everything about the product card — markup, price formatting, badge logic — lives in one place, the
ProductCelland its template. To understand or change the card, you open one component, not a partial plus three helpers plus a controller. -
Explicit dependencies. A Cell declares what it needs. There is no hidden reliance on controller-set instance variables, so it is genuinely reusable — render it anywhere, from any controller, a mailer, even another component, by handing it its inputs.
-
Real testability. This is the big one. Because a Cell is an object, you can instantiate it and assert on its rendered output directly, with no controller, no request, no full-stack integration test:
it "shows an eco badge for eco products" do html = ProductCell.new(build(:product, eco: true)).() expect(html).to have_css(".badge", text: "Eco") endFast, focused unit tests for your UI — the thing the partial-plus-helper model cannot give you.
-
Composability. Cells render other Cells, so you build complex UI from small components, each independently understandable and testable, exactly as you would with front-end components.
When to reach for it (and when not)
The usual caution. Not every partial needs to be a Cell — a one-off bit of markup used in one place is fine as a partial, and wrapping it in a component class is ceremony. Reach for view components when a piece of UI is reused across several views, when it has non-trivial presentation logic that deserves tests, or when it is complex enough that its setup is error-prone. The product card used on five pages is the perfect candidate; a one-line partial is not. As with service objects and decorators, the goal is to give structure where complexity lives, not to wrap everything reflexively.
Where this is going
It is worth seeing Cells as part of a clear direction of travel. The component model has proven itself on the front end, and the same logic applies to server-rendered UI: a real, testable, self-contained unit beats a fragment dependent on ambient state. Cells is the mature option in 2018, and the idea is gathering enough momentum that it would be no surprise to see a component abstraction become a more first-class part of Rails itself before long. Learning to think in view components now — explicit inputs, encapsulated logic, testable in isolation — is the durable skill, whichever library carries it.
Verdict
Rails’ default view layer — partials leaning on instance variables, logic in helpers and templates — does not scale to reuse or testing, for the same reasons the front end abandoned that style. View components, via Cells, make a piece of UI a real object: encapsulated, with explicit inputs, composable, and — crucially — unit-testable without a controller or a request. Use them where UI is reused or logic is non-trivial enough to deserve tests, and leave simple one-off markup as partials. The component idea has already reshaped the front end; bringing it to your server-rendered views makes them just as maintainable, and positions you for where Rails view architecture is clearly heading.