Rails ViewComponent: Herbruikbare UI-componenten, testen en performance voorbij partials
Een klant liet me afgelopen zomer hun app/views/shared/-map zien. Daar stonden 184 partials in. Eenenveertig daarvan heetten _card.html.erb in verschillende submappen, allemaal nét iets anders, allemaal op drie plekken gebruikt, allemaal ongetest. Toen ik het team vroeg hoe ze besloten welke partial ze moesten gebruiken, lachte de leadontwikkelaar en zei: “we greppen.” Die codebase was vier jaar oud en leverde nog steeds features op, maar de viewlaag was stilletjes het duurste onderdeel van de applicatie geworden om te wijzigen. We hebben hem in zes weken herschreven in Rails ViewComponent. De rendertijd ging 18% omlaag, het designsysteem had eindelijk één bron van waarheid, en grep’en op _card.html.erb was geen functieomschrijving meer.
Na negentien jaar Rails heb ik een uitgesproken mening over partials: ze waren het juiste antwoord voor 2005 en het verkeerde antwoord voor 2026. Rails ViewComponent — de gem die GitHub uit hun eigen monoliet heeft geëxtraheerd — vervangt partials door gewone Ruby-objecten die je kunt testen, documenteren en samenstellen. Deze post is het verhaal dat ik vertel aan teams die denken dat de migratie de moeite niet waard is: wat ViewComponent is, wanneer je het inzet, hoe je het opzet, welke patronen ertoe doen en welke productiecijfers de overstap rechtvaardigen.
Wat Rails ViewComponent eigenlijk is
Rails ViewComponent is een framework om herbruikbare viewcomponenten te bouwen als Ruby-klassen. Elke component is een .rb-bestand gekoppeld aan een .html.erb-template. De Ruby-klasse is het contract — de initializer-argumenten zijn de “props” die de component aanneemt, en de publieke methodes zijn de helpers die de template kan aanroepen. De template is dom: geen instance variables die uit de controller lekken, geen halfgeïnitialiseerde state, geen impliciete context.
Het mentale model dat bij de meeste teams aanslaat is React-met-ERB. Een ButtonComponent wordt aangeroepen met argumenten (render(ButtonComponent.new(label: "Save", variant: :primary))), heeft een deterministische rendering, en je kunt hem unit-testen zonder een controller te starten. Waar partials in essentie bestand-gebaseerde templates met impliciete locals zijn, zijn Rails ViewComponent-componenten expliciet, geïsoleerd en snel.
De performance komt uit compilatie. ViewComponent compileert zijn templates eenmalig bij boot tot methodedefinities op de klasse. Elke render is een methodeaanroep. Partials gaan daarentegen bij elke render door de lookup en template resolution van Action View. Voor een complexe pagina die dezelfde partial 50 keer rendert is het verschil meetbaar — meestal 5-15% op de rendertijd, soms meer.
Maar de echte winst zit niet in de milliseconden. Hij zit in wat tests, previews en expliciete interfaces doen voor de kosten van het wijzigen van de UI over een jaar.
Wanneer kies je Rails ViewComponent boven partials
Ik migreer niet elke partial. De grens die ik bij klanten trek is simpel: alles dat op meer dan één plek wordt gebruikt, of dat logica heeft voorbij het renderen van data, wordt een component. De rest blijft een partial.
Grijp naar Rails ViewComponent wanneer:
- Hetzelfde UI-element op drie of meer plekken voorkomt (cards, buttons, badges, modals, tabellen, formulierregels).
- De view conditionele logica heeft die de template opzwelt (
<% if user.admin? %>-ketens, role-based zichtbaarheid, feature-flag-vertakkingen). - Je een test wilt schrijven voor de gerenderde output — partials maken dat pijnlijk.
- Je een designsysteem bouwt en de componenten ergens wilt catalogiseren waar ontwikkelaars ze kunnen vinden.
- De view praat met een complex object en je telkens helper-methodes vindt die alleen deze view gebruikt.
Blijf bij partials wanneer:
- De view één keer op één pagina wordt gerenderd en waarschijnlijk niet hergebruikt zal worden.
- Het vooral statische opmaak is met één instance variable.
- Je nog vroeg in het project zit en abstractie voorbarig zou zijn.
De fout die ik het vaakst zie is naar één van beide extremen gaan. Of teams weigeren ViewComponent überhaupt te gebruiken omdat “partials prima werken,” of ze converteren elke partial omdat “components beter zijn.” Beide zijn fout. De kosten-batenverhouding zit in het midden.
Rails ViewComponent installeren
De gem is rechttoe rechtaan. Op Rails 7.1+ of 8.0:
# Gemfile
gem "view_component"
bundle install
bin/rails generate view_component:install
De installer voegt een autoload-pad toe voor app/components/, configureert previews, en maakt test/components/ aan met een base test class. Daarvandaan genereer je je eerste component:
bin/rails generate component Button label variant
Dit maakt drie bestanden:
app/components/button_component.rb
app/components/button_component.html.erb
test/components/button_component_test.rb
De componentklasse:
# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
VARIANTS = %i[primary secondary danger ghost].freeze
def initialize(label:, variant: :primary, type: "button", **options)
@label = label
@variant = variant
@type = type
@options = options
raise ArgumentError, "Invalid variant: #{variant}" unless VARIANTS.include?(variant)
end
def css_classes
base = "inline-flex items-center px-4 py-2 rounded font-medium transition-colors"
"#{base} #{variant_classes}"
end
private
attr_reader :label, :variant, :type, :options
def variant_classes
case variant
when :primary then "bg-blue-600 text-white hover:bg-blue-700"
when :secondary then "bg-gray-200 text-gray-900 hover:bg-gray-300"
when :danger then "bg-red-600 text-white hover:bg-red-700"
when :ghost then "bg-transparent text-gray-700 hover:bg-gray-100"
end
end
end
De template:
<%# app/components/button_component.html.erb %>
<button type="<%= type %>" class="<%= css_classes %>" <%= options.to_html_attributes %>>
<%= label %>
</button>
En hij wordt gerenderd vanuit elke view of een andere component:
<%= render(ButtonComponent.new(label: "Save", variant: :primary, data: { turbo_method: :post })) %>
Het eerste dat ontwikkelaars opvalt is dat de template geen if-statements heeft, geen helper-aanroepen, geen instance variables uit controllers. Elke input is expliciet. Elke output is deterministisch.
Slots: het patroon dat yields vervangt
Partials handelen multi-section layouts af met content_for en yield. ViewComponent doet dat met slots, en slots zijn de enige feature die de migratie de moeite waard maakt voor complexe UI.
Een card-component met header, body en footer:
# app/components/card_component.rb
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_many :actions, "ButtonComponent"
def initialize(variant: :default)
@variant = variant
end
end
<%# app/components/card_component.html.erb %>
<div class="card card-<%= @variant %>">
<% if header? %>
<div class="card-header"><%= header %></div>
<% end %>
<div class="card-body">
<%= content %>
</div>
<% if actions.any? %>
<div class="card-actions">
<% actions.each do |action| %>
<%= action %>
<% end %>
</div>
<% end %>
<% if footer? %>
<div class="card-footer"><%= footer %></div>
<% end %>
</div>
Hem aanroepen:
<%= render(CardComponent.new(variant: :elevated)) do |card| %>
<% card.with_header do %>
<h2>Order #<%= @order.id %></h2>
<% end %>
<p>Geplaatst <%= time_ago_in_words(@order.created_at) %> geleden.</p>
<% card.with_action(label: "Refund", variant: :danger) %>
<% card.with_action(label: "Print", variant: :secondary) %>
<% card.with_footer do %>
Totaal: <%= number_to_currency(@order.total) %>
<% end %>
<% end %>
Twee dingen om op te merken. Ten eerste laat renders_many :actions, "ButtonComponent" de slot zowel content blocks als constructor-argumenten voor een andere component accepteren. card.with_action(label:, variant:) aanroepen is suiker voor “bouw een ButtonComponent met deze argumenten en voeg hem toe aan de slot.” Ten tweede zijn header? en footer? automatisch gegenereerde predicaten waarmee de template netjes degradeert als slots leeg zijn.
Slots elimineren de hele content_for/yield-dans, en ze maken complexe layouts samenstelbaar op een manier die partials nooit voor elkaar hebben gekregen.
Previews: de feature die de migratie verkoopt
De reden dat ik Rails ViewComponent kan verkopen aan sceptische teams is view_component/previews. Elke component krijgt een Storybook-achtige previewpagina op /rails/view_components. Je schrijft eenmalig een previewklasse en je designsysteem heeft een levende catalogus.
# test/components/previews/button_component_preview.rb
class ButtonComponentPreview < ViewComponent::Preview
def primary
render(ButtonComponent.new(label: "Save", variant: :primary))
end
def secondary
render(ButtonComponent.new(label: "Cancel", variant: :secondary))
end
def danger
render(ButtonComponent.new(label: "Delete account", variant: :danger))
end
def with_long_label
render(ButtonComponent.new(label: "Save and continue to next step", variant: :primary))
end
# @param label text
# @param variant select [primary, secondary, danger, ghost]
def playground(label: "Click me", variant: :primary)
render(ButtonComponent.new(label: label, variant: variant.to_sym))
end
end
Bezoek /rails/view_components/button_component/primary en je ziet de gerenderde knop. Het playground-voorbeeld met @param-annotaties geeft je Lookbook-style live controls als je de lookbook-gem naast ViewComponent installeert.
Ik heb dit de afgelopen twee jaar bij elke Rails 8-klant uitgerold. Het verandert het gesprek tussen design en engineering. Designers kunnen zien wat er bestaat. Engineers kunnen vinden wat ze kunnen hergebruiken. Code review op UI-wijzigingen gaat van “ziet er goed uit” naar “open de preview, klik door de varianten, ship het.”
Rails ViewComponent testen
Dit is de feature die de huur betaalt. Componenten zijn geïsoleerd unit-testbaar:
# test/components/button_component_test.rb
require "test_helper"
class ButtonComponentTest < ViewComponent::TestCase
test "renders primary variant by default" do
render_inline(ButtonComponent.new(label: "Save"))
assert_selector "button.bg-blue-600", text: "Save"
end
test "renders danger variant" do
render_inline(ButtonComponent.new(label: "Delete", variant: :danger))
assert_selector "button.bg-red-600", text: "Delete"
end
test "raises on invalid variant" do
assert_raises(ArgumentError) do
ButtonComponent.new(label: "Save", variant: :neon)
end
end
test "passes data attributes through" do
render_inline(ButtonComponent.new(label: "Save", data: { turbo_method: :post }))
assert_selector "button[data-turbo-method='post']"
end
end
Deze tests draaien in milliseconden. Geen browser, geen controller, geen database. Je schrijft er twintig in de tijd die je nodig hebt voor één Capybara-systeemtest. Voor een designsysteem met tachtig componenten is dat het verschil tussen testbare UI en niet-testbare UI.
Ik draai componenttests als onderdeel van de reguliere Minitest-suite — zie mijn aanpak voor snelle Minitest-fixtures voor hoe ik de rest van de suite snel genoeg houd zodat het toevoegen van componenttests geen pijn doet.
Performance: echte benchmarks
Ik heb Rails ViewComponent vergeleken met partials op drie productie-apps. Het patroon is consistent.
Een pagina die 200 instanties van dezelfde component in een lijst rendert:
- Partial-versie: 142 ms server-rendertijd, p50.
- ViewComponent-versie: 121 ms server-rendertijd, p50.
- Verbetering: 14,8%.
Een complex dashboard met 40 gemengde componenten per pagina:
- Partial-versie: 89 ms p50, 156 ms p95.
- ViewComponent-versie: 76 ms p50, 128 ms p95.
- Verbetering: 14,6% p50, 18% p95.
Een eenvoudige pagina met drie componenten:
- Partial-versie: 22 ms.
- ViewComponent-versie: 21 ms.
- Verbetering: ruis.
De afdronk: ViewComponent is op renderzware pagina’s noemenswaardig sneller en niet te onderscheiden op lichte. Het maakt geen trage pagina snel — daarvoor zie mijn N+1-querygids — maar het zal je ook niet vertragen, en de architecturele winst stapelt sowieso op.
Migratiestrategie: hoe ik dit uitrol
Ik herschrijf nooit de hele viewlaag in één pull request. Drie weken strangler-pattern-migratie is wat ik klanten aanraad:
Week 1: pak de high-traffic, high-reuse partials. Buttons, badges, cards, formulierregels. Converteer ze één voor één. Voeg componenttests toe terwijl je bezig bent. Laat de partial staan, render de component vanuit de partial, bewijs dat het gedrag identiek is, en update daarna de aanroepers in batches.
Week 2: pak de layout-zware partials met slots aan. Modals, dropdowns, tabs, accordions. Hier verdienen slots zich terug. De componentversies zijn meestal korter dan de partial-versies omdat al het content_for-leidingwerk verdwijnt.
Week 3: bouw de designsysteem-index. Schrijf previews voor elke component. Zet Lookbook op. Houd één design review-sessie waarin je de volledige previewpagina doorloopt. Dit is het moment dat teams beseffen dat ze drie iets verschillende button-stijlen hebben en kiezen welke ze houden.
Ik migreer niet elke partial. Wat één keer wordt gerenderd en waarschijnlijk niet verandert, hoeft geen component te worden. De 80/20-regel geldt — 20% van de partials veroorzaakt 80% van de wijzigingskosten.
Dit patroon is hetzelfde als wat ik gebruik voor concerns spaarzaam extraheren: abstraheer wat hergebruikt wordt, laat staan wat lokaal is.
Productiepatronen en valkuilen
Stop geen Active Record-queries in componenten. Componenten zijn view-objecten. Ze nemen data, ze renderen markup. Als een UserCardComponent user.posts.recent query’t in zijn initializer, heb je je N+1-probleem verplaatst van de controller naar de component en moeilijker vindbaar gemaakt. Geef vooraf geladen data mee.
Gebruik polymorfe helpers voorzichtig. ViewComponent ondersteunt link_to, form_with en de rest van de Action View-helpers, maar niet alle custom helpers — je current_user, bijvoorbeeld, is niet automatisch beschikbaar. Geef hem mee als argument of override def helpers om bloot te leggen wat je nodig hebt. Ik geef hem liever mee; expliciet wint het altijd van impliciet.
Slots hebben kosten. renders_many alloceert een array per render. Voor een component die honderden keren in een strakke loop wordt gerenderd, kies waar mogelijk constructor-argumenten boven slots. Dit doet zelden ertoe, maar wel op renderzware admin-tabellen.
Componentgenerators zijn je vrienden. Ik breid de standaardgenerator altijd uit zodat hij naast de component ook een previewbestand scaffolds. Zie mijn gids voor custom Rails generators voor hoe ik dat opzet — vijf minuten eenmalig, uren bespaard over de levensduur van een project.
Naamgeving doet meer ertoe dan je denkt. Ik gebruik het Component-suffix op elke klasse (ButtonComponent, niet Button) en namespace per domein zodra het designsysteem voorbij ~30 componenten groeit: Forms::FieldComponent, Layout::PageHeaderComponent. Vlakke namespaces stoppen rond de 40 componenten met schalen.
Wanneer Rails ViewComponent niet het antwoord is
Als je app vooral formulierpagina’s en eenmalige views is, is ViewComponent overkill. Blijf bij partials. Als je een designsysteem-catalogus bouwt voor een niet-Rails frontend (React, Vue), gebruik dan direct Storybook. Als je server-gerenderde componenten nodig hebt die gedeeld worden over meerdere Rails-apps, werkt ViewComponent maar wil je misschien ook kijken naar je componenten als gem verpakken — dat is een langer gesprek.
Voor iedereen anders — de meeste Rails-apps met een echt UI-oppervlak — is Rails ViewComponent de goedkoopste architecturele upgrade die ik ken. Hij kost drie weken, betaalt zich terug in rendertijd en tests, en verandert de viewlaag van het duurste gebied om te wijzigen in een van de eenvoudigere.
FAQ
Is Rails ViewComponent compatibel met Hotwire en Turbo?
Ja, volledig. Componenten renderen dezelfde HTML als partials en werken transparant met Turbo Frames, Turbo Streams en Stimulus. Ik render Turbo Stream-broadcasts continu vanuit componenten — de syntaxis is hetzelfde als renderen vanuit partials, en het is de onderliggende HTML waar Turbo om geeft.
Moet ik alles migreren om ViewComponent te gebruiken?
Nee. ViewComponent en partials bestaan in dezelfde view naast elkaar. Ik laat meestal 60-70% van de partials staan en migreer alleen de high-reuse, high-change exemplaren. Kies de partials waar de kosten van inconsistentie het hoogst zijn — buttons, cards, modals, formuliervelden — en laat pagina-specifieke markup partials.
Hoe verhoudt ViewComponent zich tot Phlex?
Phlex is een recentere Ruby-native templating-bibliotheek waar de hele component één Ruby-klasse is — geen .html.erb-bestand. Phlex is sneller dan ViewComponent op benchmarks en de all-Ruby DSL voelt schoner als je het prettig vindt om markup in Ruby te schrijven. ViewComponent is volwassener, heeft betere preview-tooling, en laat designers .html.erb direct bewerken. Voor de meeste teams is ViewComponent nog steeds de juiste default. Als je team comfortabel is met markup in Ruby schrijven en je geen designer-bewerkbare templates nodig hebt, is Phlex een serieuze blik waard.
En de performance — is ViewComponent altijd sneller dan partials?
Op renderzware pagina’s is hij 10-20% sneller omdat templates eenmalig worden gecompileerd tot Ruby-methodes. Op eenvoudige pagina’s met een handvol partials zit het verschil in de ruis. Migreer niet naar ViewComponent louter voor performance — migreer voor testbaarheid, slots en de designsysteem-catalogus. De performance is een bijproduct.
Hulp nodig bij het invoeren van Rails ViewComponent of het bouwen van een designsysteem binnen een Rails 8-app? TTB Software doet dit werk als fractional CTO-engagement. We leveren al negentien jaar Rails op.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation