The Problem with ERB at Scale
Lesson 1 — The problem with ERB at scale
Where Rails views start
When you generate a new Rails app and scaffold your first resource, everything feels clean. You get a handful of .html.erb files, each one mapping neatly to a controller action. The structure is obvious. The separation feels right.
app/views/posts/
index.html.erb
show.html.erb
new.html.erb
edit.html.erb
_form.html.erbThis works beautifully for a while. ERB is readable, the file-per-action convention is easy to follow, and partials handle the obvious repetition. For a simple CRUD app, this is genuinely fine.
The trouble starts when the UI grows up.
The complexity creep
Consider a posts/index.html.erb six months into a real project. It probably looks something like this:
<% content_for :title, "Posts" %>
<% content_for :sidebar do %>
<%= render "shared/filters", filters: @filters %>
<% end %>
<div class="page-header">
<h1>Posts</h1>
<%= link_to "New Post", new_post_path, class: button_classes(:primary) %>
</div>
<% if @posts.empty? %>
<%= render "shared/empty_state", message: "No posts yet" %>
<% else %>
<div class="post-grid">
<% @posts.each do |post| %>
<%= render "posts/post_card",
post: post,
show_author: current_user.admin?,
highlight: params[:highlight_id] == post.id.to_s %>
<% end %>
</div>
<% end %>
<%= render "shared/pagination", pagy: @pagy %>That’s already pulling in four different partials, calling a helper (button_classes), reading from content_for, and accessing current_user implicitly through the view context. And this is a tidy version.
Now ask yourself: where does button_classes come from? Where is the :sidebar content_for consumed? What does _post_card.html.erb accept as locals? What happens if you render _post_card from a different context where current_user isn’t available?
These questions don’t have obvious answers. You have to go hunting.
The four-way tension
ERB views in a mature Rails app are pulled in four directions simultaneously, and none of them play nicely together.
Partials handle repetition, but they’re essentially untyped function calls. Their interface is implicit — there’s no contract. A partial can silently access instance variables set by the controller, which makes it secretly coupled to a specific rendering context. Rename @posts to @pagy_posts and suddenly a partial three levels deep breaks with a cryptic nil error.
Helpers handle logic that “doesn’t belong in the view”, but they’re globally scoped. Every helper in every *_helper.rb file is available everywhere. They accumulate. Teams hesitate to delete them because it’s hard to know what still uses them. And because they return HTML strings, they’re invisible to your test suite unless you test them explicitly.
Layouts handle page structure, but they communicate with views through yield and content_for — an implicit, stringly-typed protocol. When a view calls content_for :sidebar, it is making an assumption about what the layout does with a :sidebar slot. Break the layout, break every view silently.
Instance variables are the invisible thread that binds it all together. Controllers set them, layouts read them, views use them, partials inherit them. There’s no declared interface anywhere. The controller and the view are tightly coupled through shared mutable state, but that coupling is completely invisible in both files.
The result is a web of implicit dependencies. A senior developer can navigate it through experience and convention, but it resists refactoring, resists testing in isolation, and actively fights the instinct to extract reusable UI.
What “complexity” actually looks like
Here’s a concrete symptom: imagine you want to extract a PostCard that can be used in three places — the posts index, the search results page, and a dashboard widget. With ERB, you create _post_card.html.erb and start wiring it up. Then you discover:
- The index passes
show_author:as a local, but the dashboard doesn’t need it - The search results need a
highlight_terms:local that the other two don’t - The dashboard is rendered inside a Turbo Frame, so links need a
data-turbo-frameattribute - One context calls the partial through a collection (
render @posts), another calls it individually
You end up with a partial that has grown a sprawling set of optional locals, internal conditionals, and context-sniffing logic. Or you end up with three slightly different partials that share duplicated markup. Either way, you’ve lost the encapsulation you were aiming for.
The partial is not a component. It’s a macro — a text substitution with some variable interpolation. It has no object identity, no explicit interface, no lifecycle, and no way to enforce how it’s used.
The testing dead end
This is where the ERB complexity really hurts. How do you test a partial in isolation?
In theory: render it in an ActionView test context with stub locals and assert on the output. In practice: almost nobody does this. The setup is awkward, the test is fragile, and the payoff feels low.
So view logic goes untested. Helpers get a few unit tests if you’re disciplined. But the actual rendered output? You trust integration tests and your eyes.
This has a compounding effect. Because view code isn’t tested, developers are reluctant to refactor it. Because it isn’t refactored, it grows. Because it grows, it becomes harder to test. The cycle continues until the views directory becomes the part of the codebase nobody wants to touch.
The insight
The core problem is not ERB the templating language. ERB is fine for simple cases. The problem is that Rails gives you no structured way to create encapsulated, reusable UI components with explicit interfaces.
What you actually want is something like this:
- A component is a Ruby object with a defined initializer — its interface is explicit and type-checkable
- A component only renders what it was given — no implicit instance variable access
- A component can be rendered from anywhere — controller, another component, a mailer, a test
- A component can be tested as a unit — instantiate it, render it, assert on the output
- A component owns its markup — no hunting across multiple files to understand what it produces
This is exactly what Phlex gives you. And it gives it to you in pure Ruby, with no new templating syntax to learn.