Hotwire Decisions: When to use Turbo Frames, Turbo Streams, and Stimulus

Progressive Enhancement as a guide to choosing which tool to use in Hotwire

The Hotwire framework is a renewed JavaScript framework in Rails 7.  It's designed to provide the benefits of a SPA's faster interactivity while sticking closely to classic server-side rendered Rails views and minimal JavaScript.  It takes a very different stance from SPA frameworks like React or Vue: instead of sending JSON over the wire to process and render via JavaScript on the client, it sends small chunks of HTML which are inserted into appropriate sections of the page by JavaScript.  Just like a SPA this avoids re-rendering the entire page and makes it feel faster.  As of Rails 7, this is now the default; though as is usual in Rails, several other paths are left open for developers to choose from.

Note: This article covers several examples of when you might want to use each tool. If you're looking for the tldr version, feel free to jump to the summary at the bottom.

 

Stimulus and Turbo

For most applications, Hotwire can be broken down into two main components:

Stimulus (or Stimulus controllers) which serve as small, ideally portable, and reusable pieces of JavaScript.  These controllers add additional interactivity to a page.  Stimulus comes with a set of automatic bindings that both define a standard method of connecting to elements and to events on a page, as well as helping to guide and constrain how one does that.

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.

Progressively Enhance

Experienced Rails devs will likely have heard the term progressive enhancement.  A very abbreviated take on what that means might be to use HTML first, then apply CSS, and finally Javascript only when you need it.  You work your way up to more complex solutions: HTML ⇨ CSS ⇨ Javascript.  It also gives the developer an opportunity along the way to maintain a working, though less enhanced page when some feature (usually JavaScript) isn't working in a particular user's browser.

With Hotwire, it's still about progressive enhancement, but we expand that "JavaScript" section. We add in some tools that allow the developer to write far less of their own JavaScript and at the same time achieve that quick "SPA feel". A simplified summary with Hotwire might be: HTML ⇨ CSS ⇨ Turbo Frames ⇨ Turbo Streams ⇨ Stimulus ⇨ Other JS libraries.

More like guidelines than actual rules

more like guidelines than rulesOf 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 connect() and disconnect() to make sure Turbo plays nice.  Likewise, if your CSS framework needs a small JavaScript library, just plug it in and use it.  No fuss.

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.

turbo-frame edit button replacement example

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 loading="lazy" on the element.  You'll hopefully be able to re-use the existing partial for that section and if it's important to your application you should be able to keep the page working both with and without JavaScript.
 

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 id of current_message.

turbo stream example

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:

  1. Rendering chat responses

  2. Updating notification icons such as “user online” or “N new messages”

Both of these tend to be very asynchronous; we don’t know when a user will go on/off-line or when a new message might come in.  Combining Turbo Streams with Action Cable allows us to render small pieces of HTML into any page that is subscribed to those Streams.  And we can do that without writing any JavaScript.  For example joining a global stream named “world_messages” can be as simple as: <%= turbo_stream_from 'world_messages' %>.

 

Stimulus Use Case: Tying in other Javascript tools

With Turbo, elements that need binding to other JS tools could appear or disappear from the page at times other than the typical window.onload or 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. 

// app/javascript/controllers/popper_controller.js

import { Controller } from "@hotwired/stimulus"
import "popper"
import "bootstrap"

export default class extends Controller {
  static targets = [ 'tooltip' ]

  tooltipTargetConnected(element) {
    new bootstrap.Tooltip(element)
  }
}

Popper tooltip inside turbo-streamThat’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. 

 

Stimulus Anti-patterns

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. 

 

Summary: Start Simple and Progressively Enhance

  1. Start with HTML & CSS.  This should always be your main course if you didn’t start in API mode.
  2. Prefer server-side rendered responses.  Turbo Drive will make these SPA-like fast anyway.  
  3. Progressively enhance with Turbo Frames by choosing independent regions of the page that can act on their own. 
  4. Do more by moving from Turbo Frames to Turbo Streams.  Bringing multiple changes to a single response or real-time async updates over WebSockets.
  5. 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.

 

Hero Photo by Cesar Carlevarino Aragon on Unsplash

Continue the conversation.

Lab Zero is a San Francisco-based product team helping startups and Fortune 100 companies build flexible, modern, and secure solutions.