Hotwire Decisions: When to use Turbo Frames, Turbo Streams, and Stimulus
Progressive Enhancement as a guide to choosing which tool to use in Hotwire
Stimulus and Turbo
For most applications, Hotwire can be broken down into two main components:
And Turbo, which many Rails developers may recognize as the successor to turbo-links. Turbo itself can be split into 3 tools: Turbo Drive, Turbo Frames and Turbo Streams. I'm listing them here in the order that I think one should generally elect to use them—from simplest to most complex. Some old-hat Rails developers may not have the fondest memories of turbo-links; rest assured that the new Turbo tools are significantly different and at least in this developer's experience, mostly keep out of the way until you’re ready to use them.
Strada, a related mobile integration tool, hasn’t been released yet so we won’t discuss it now.
With all of these new tools, how do you know which one to pick up for a particular task?
That's a question I saw come up a lot on a recent project. All of these Hotwire tools are fairly new and the available literature is still catching up. Especially looking through the Turbo docs, Streams and Frames seem very similar at first glance. It’s also easy to make the mistake of thinking “I need some JS, so I’ll write a Stimulus controller.” Or to jump in with both feet on a Turbo Streams solution when you really don’t need that level of complexity. As we gained experience with these tools though, we were able to form a loose hierarchy to guide us in choosing the right one.
More like guidelines than actual rules
Of course, this pattern is over-simplified. If you've already got a separate component to do a job (maybe a 3rd-party uploader) there's no reason to rewrite it using Turbo if you're happy with it. Just tie it into the page using a Stimulus controller and use
That's the simplified approach, now let’s talk in more detail about what each tool is good for. We’ll take you through some common use cases that call for enhancing with Turbo Frames, Streams and Stimulus.
If you can do it via server-side rendering…
Great! This is classic Rails, but now with Turbo we have some new options for progressively enhancing our app.
Start with Turbo Drive. If you’re on Rails 7 there's a good chance it's working already without you even noticing. Using Turbo Drive will provide that snappy SPA feeling and usually takes no extra effort from the developer, except when tying in other JS tools. Your site will also keep working fine if JS is turned off. It's an easy win!
Turbo Frames Use Case: Focused Interactivity
Once you need more interactivity than you can achieve with just HTML, progressively enhance it with Turbo Frames by peeling off parts of the page and tying them into their own resourceful controllers and views. An example of this is a "new comment" or "edit comment" button that loads a comment form in-line in the page. You can even add links or buttons that target other turbo frames on the page. That means your comment form doesn't have to appear where the button was, but instead could appear in a side-bar or as the first entry beneath the post.
Perhaps you have a corner of the page that shows trending items or a dashboard widget with its own isolated functionality. Those could both be good targets for Turbo Frame enhancement. As you enhance, it's also a good time to refactor that section so it has its own controller. You could even wait to lazy load it when it becomes visible by setting
Turbo Streams Use Case: You need to alter multiple separate sections of the page at once
If you've already got a Turbo Frame working but find that another section of the page also needs to be updated at the same time, it might be time to break out Turbo Streams. It's possible to embed a
turbo_stream in a
turbo_frame response and it'll get sent to the correct section of the page. It's fairly straightforward:
<%= turbo_frame_tag 'show_package' do %> <span>Your Package</span> <%= render @package %> <%= turbo_stream.update 'current_message' do %> Currently viewing package <span class="text-info">#<%= @package.id %></span> <% end %> <% end %>
In the above example, the
show_package turbo-frame content will be updated with the package information, but the turbo-stream content will be pulled out and automatically displayed in a completely different section of the page. In this case we just need an element somewhere on the page with an
In the above we enhanced a turbo-frame with a turbo-stream, but it’s worth noting that we could have switched this to send two streams in one response instead. You may want to do that in code for clarity or if the turbo-stream portion of the response is essential. You’ll also need to change the views file-name.
<%= turbo_stream.update 'show_package' do %> <span>Your Package</span> <%= render @package %> <% end %> <%= turbo_stream.update 'current_message' do %> Currently viewing package <span class="text-info">#<%= @package.id %></span> <% end %>
If this is for a GET request you’ll likely also need to specifically request the turbo_stream format by setting
data-turbo-stream on the link or form. E.g.
<%= link_to "Show", package, class: 'btn btn-sm btn-outline-primary', 'data-turbo-stream' => true %>
Turbo Streams Use Case: Rendering async partials over Websockets
The requests & responses described above go over the same http(s) web connection the rest of your page does (worth noting these will usually be XHR thanks to Turbo). However, if you’re willing to go a little further and set up ActionCable (Websockets in Rails), then some interesting new options open up to you. With ActionCable and Turbo together you can render a turbo-stream over a Websocket in response to other user or even non-user events.
A couple of short-and-sweet examples for when you might want to do this:
Rendering chat responses
Updating notification icons such as “user online” or “N new messages”
world_messages” can be as simple as:
<%= turbo_stream_from 'world_messages' %>.
With Turbo, elements that need binding to other JS tools could appear or disappear from the page at times other than the typical
DOMContentLoaded. Stimulus can be a great way to integrate those tools into the Turbo page life-cycle so they continue working as expected.
For example, Popper.js is used in Bootstrap 5 for tooltips. We found that it didn’t bind correctly to elements injected into the page via Turbo, but it was straightforward to fix this with just a few lines of Stimulus.
That’s it; just add
data-popper-target=’tooltip’ to an element. When it appears on the page Stimulus will automatically call the
tooltipTargetConnected method and the tooltip will be set up and ready to go. For example:
<a href="#" data-popper-target="tooltip" data-bs-title="Hi, I'm a tooltip!"> Hover over me for a surprise </a>
This will work whether the element arrives via Turbo Frames, Turbo Streams or as part of the initial page load. The real trick here is just being aware of some of the “magic” methods that Stimulus adds for
targets. Using Stimulus in this way makes it a simple glue between other JS tools and the Turbo parts of the Hotwire stack. You’ll likely find that other JS widgets can often be integrated in a similar manner.
Stimulus Use Case: Interactive DOM updates
The other major use-case is for really anything you wouldn't want to do with regular Turbo. In general that might be user-input management or small DOM updates and page changes that are really just too small to be worth going back to the server for. For example, a character-counter on an input or calculating a sum from multiple inputs are good use-cases. Full input validation might not be though. It's also useful for integrating other pieces of Turbo together; for example refreshing a particular turbo-frame based on user-input or a timer is easy to implement in Stimulus.
Word of caution: if you find yourself starting to want some kind of templating in Stimulus, take a step back and think about whether you could do this with a Turbo Frame. You'll probably end up with code that’s easier to understand, plus you’ll likely avoid writing a Stimulus controller that won’t be re-usable elsewhere.
Also, it’s good to keep in mind that while you can do real-time input validation in Stimulus, you still want to do final validation on the back end as well. There may be circumstances where the JS doesn’t run at all, or fails in an unanticipated way. There may even be users who are a little more sneaky in the data they try to pass through. In my opinion, it's worth considering whether it might be simpler to just implement it one time only and use Turbo instead. Those round-trips over Turbo are still pretty quick and if you need it, a Stimulus controller to auto-submit is likely to be pretty reusable elsewhere in your app.
I’ve said “re-usable” several times in the above paragraphs because I think it's a good goal to aim for. If your controller starts to morph into a whole-page hall-monitor you might want to reconsider. I’ve written some of those before and they haven’t felt very maintainable 6 months later. Trying to build small, focused and re-usable Stimulus controllers usually left me happier with the results.
- Start with HTML & CSS. This should always be your main course if you didn’t start in API mode.
- Prefer server-side rendered responses. Turbo Drive will make these SPA-like fast anyway.
- Progressively enhance with Turbo Frames by choosing independent regions of the page that can act on their own.
- Do more by moving from Turbo Frames to Turbo Streams. Bringing multiple changes to a single response or real-time async updates over WebSockets.
- Use Stimulus to tie it all together and handle the loose ends. Connect up tools like Popper or Uppy and do small custom DOM updates to elements based on user input. But reconsider if you find yourself writing JS to inject large chunks into the DOM or building large un-reusable controllers.
Continue the conversation.
Lab Zero is a San Francisco-based product team helping startups and Fortune 100 companies build flexible, modern, and secure solutions.