View Components Over Turbo Streams with Hotwire
TL;DR:
You can render ViewComponents over Turbo Streams instead of partials. This provides:Simply replace
- Better performance – components render faster than partials, especially for large collections.
- Improved maintainability – encapsulated logic keeps your views clean and easier to track.
- Testability – you can unit-test components independently.
- UI consistency – the same component can be used in Turbo Streams, regular views, emails, or previews.
partial: "..."
withrenderable: YourComponent.new(...)
in your turbo stream actions.
Remember when Hotwire launched in December 2020? It feels like it was just yesterday. The video on Hotwire demonstrating this new Rails feature (using a chat that sends messages over the wire as an example) was amazing, and I fell in love with it instantly.
Time has passed (almost five years at the time of writing!), and the Hotwire ecosystem now includes many new features. However, there is one feature that isn’t widely known (introduced in 2023), and I want to share it with you today. It will likely make your life easier and provide benefits that I personally find awesome.
Let's start with a quick recap.
Turbo Streams 101
Turbo Streams allow Rails applications to push updates to the client over WebSockets (or any ActionCable channel) without requiring a full page reload. A typical broadcast looks like this:
Turbo::StreamsChannel.broadcast_append_to(
"chat_room_1",
target: "messages",
partial: "messages/message",
locals: { message: @message }
)
Here, Rails renders the message partial for a new message and sends the HTML over the wire. Simple, effective—but it has a downside.
The Problem with Partials
Partials are quick to write, but as your app grows, they can become difficult to maintain:
- Logic can leak into partials, making them hard to test.
- Reuse is limited; in a large application, partials can become hard to document, track, and reuse across different views.
- Rendering large collections of partials can be slower compared to optimized component rendering.
This is where ViewComponents come in.
Introducing ViewComponents
ViewComponent is a library that encourages encapsulation of view logic into reusable, testable Ruby classes:
class MessageComponent < ViewComponent::Base
def initialize(message:)
@message = message
end
end
You can then render the component in your views:
<%= render(MessageComponent.new(message: @message)) %>
Benefits:
- Encapsulation: All logic related to rendering a message lives in the component.
- Reusable: Components can be reused across views and streams.
- Testable: You can unit-test components independently of controllers or views.
- Performance: Benchmarks show that ViewComponents can be ~2.5x faster than partials.
Rendering Components Over Turbo Streams - an EASY change!
Turbo::StreamsChannel.broadcast_append_to(
"chat_room_1",
target: "messages",
renderable: MessageComponent.new(message: @message)
)
Here’s what happens:
MessageComponent
generates the HTML for the message.- The HTML is wrapped in a
<turbo-stream>
tag by Turbo Streams. - ActionCable pushes it to the client in real time.
This approach gives you all the advantages of ViewComponents while keeping the real-time behavior of Turbo Streams.
Check demo at Stremeable
Why It’s Beneficial
Performance: Rendering components is often faster than partials, especially for complex views or large collections. Components can avoid repeated template lookups and make heavy use of Ruby’s object-oriented optimizations.
Maintainability: Components encapsulate logic, so your views stay clean. Updating how messages are displayed requires changing only the component, not multiple partials across the app.
Testability: You can write unit tests for components rendering under different conditions. This is much harder with partials that rely on complex controller contexts.
Consistency Across UI: The same component can be used in Turbo Streams, regular views, emails, or previews. This ensures UI consistency and reduces duplication.
If you want to try it yourself, here is the repo: Hotwire Chat using ViewComponents where I did a quick demo of message rendering using ViewComponents.
Benchmarks
Message rendering benchmark
RAILS_ENV=production bin/rails runner benchmarks/messages_benchmark.rb
Running benchmark on 100 messages...
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +PRISM [x86_64-linux]
Warming up --------------------------------------
partial 53.000 i/100ms
component 111.000 i/100ms
Calculating -------------------------------------
partial 531.172 (± 2.4%) i/s (1.88 ms/i) - 5.353k in 10.083752s
component 1.070k (± 4.5%) i/s (934.93 μs/i) - 10.767k in 10.088036s
Comparison:
component: 1069.6 i/s
partial: 531.2 i/s - 2.01x slower
Turbo stream append benchmark
RAILS_ENV=production bin/rails runner benchmarks/turbo_streams_benchmark.rb
Preparing Turbo Stream benchmark with 100 messages...
Running Turbo Stream benchmark...
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +PRISM [x86_64-linux]
Warming up --------------------------------------
turbo_stream partial 14.000 i/100ms
turbo_stream component 22.000 i/100ms
Calculating -------------------------------------
turbo_stream partial 143.450 (± 4.2%) i/s (6.97 ms/i) - 1.442k in 10.069179s
turbo_stream component 219.430 (± 4.1%) i/s (4.56 ms/i) - 2.200k in 10.045082s
Comparison:
turbo_stream component: 219.4 i/s
turbo_stream partial: 143.5 i/s - 1.53x slower
Conclusion
Switching from partials to ViewComponents in Turbo Streams is a small change that pays off big: faster rendering, cleaner code, easier testing, and more reusable UI elements. Let me know what you think about it. Thanks for reading! :)