How to Process a Turbo Stream Response in JavaScript using Stimulus

How to Process a Turbo Stream Response in JavaScript using Stimulus

It’s common to make HTTP fetch request to get some data from the server. It’s also convenient to update the HTML via Turbo Stream responses. What if you could combine the benefits of them both? This post shows you how to process Stream responses in JavaScript, without mucking around with the DOM.

6 min read

TL;DR: If you want to process (append, replace, etc.) a Turbo Stream response received from a fetch request in JavaScript, simply render the stream HTML with Turbo.renderStreamMessage(html) and let Turbo take care of it, instead of manipulating the DOM yourself.

Here's a simple Stimulus controller showing how it works:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="replace"
export default class extends Controller {
  // click->replace#swap
  swap() {
    fetch("/test/replace", {
      method: "POST",
      headers: {
        Accept: "text/vnd.turbo-stream.html"
      .then(r => r.text())
      .then(html => Turbo.renderStreamMessage(html))

    // html: <turbo-stream action="replace"> ...</turbo-stream>

That's it. Turbo will process the stream response as usual, just like it does for a stream message in response to a form submission or from a WebSocket. This lets you render your view templates on the server with ERB, without duplicating them on the client.

A Little Backstory

While working on a recent client project, I came across an interesting challenge. I am not sure if the way I've solved it is the optimal way (let me know if you have a better solution), but sharing it nonetheless, since the general technique to process Turbo Streams in JavaScript can be useful to others.

Here's what I was trying to do: Allow users to upload images by dragging and dropping into a simple textarea that contains Markdown text and let them copy the public URL of the uploaded image in the markdown format.

Here's what the end result looks like:

  1. Once the image is dropped onto the editor, it's sent to the server for uploading to the cloud.
  2. After the server stores the image on the cloud, it sends the public URL of the image back to the client.
  3. The client JavaScript code displays that URL in a modal dialog with a copy button so that the user can copy + paste it into the (markdown) editor (this responsibility belongs to client since it's the one who initiated the fetch request and got the response).
I know, I know... just use ActionText and be done with it. But I love Markdown and how it makes my content portable. Also, my experience with ActionText is that it's very, very easy to get started but can be harder to customize, e.g. embed stuff, inline source code, etc. Finally, the exported content and attachments only work with Trix and ActionText, something I didn't want on this specific project.

For uploading the image, I created a Stimulus controller whose action was triggered after the image was dropped. In this action, I made a fetch call to the server, and submitted the image. In the response, I got back the image URL via a JSON response.

Here's the complete controller at this point:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="image-uploader"
export default class extends Controller {
  // dragover->image-uploader#handleDrag
  handleDrag(e) {
    // Prevent default behavior (Prevent file from being opened)

  // drop->image-uploader#upload
  upload(e) {

    if (e.dataTransfer.items) {
      // Use DataTransferItemList interface to access the file(s)
      [...e.dataTransfer.items].forEach((item, i) => {
        // If dropped items aren't files, reject them
        if (item.kind === "file") {
          const file = item.getAsFile();
          console.log(`uploading the image ${}`);

  async uploadFileToServer(file) {
    const formData = new FormData();
    formData.append('file', file);

    const response = await fetch("/test/upload", {
      method: "POST",
      headers: {
        "X-CSRF-Token": this.getCsrfToken()
      body: formData
    const image_url_response = await response.json()

    // What to do with this JSON??

  getCsrfToken() {
    return document.querySelector('meta[name="csrf-token"]').content;

I want to point your attention to the uploadFileToServer method. After making the fetch call posting the image file, the server stored the image and returned the image URL via a JSON response.

The question is: what to do with this URL?

To remain consistent with the rest of the app UI, I wanted to display a modal containing the URL along with a copy button that the user could click to copy it.

The best way to display modals in a Rails + Hotwire app is to either use Turbo Frames or Turbo Streams, keeping the view templates on the server. However, since the Stimulus controller initiated the fetch request, I couldn't just receive and process the Turbo response automatically.

One obvious way to achieve this was to create and use a pure JavaScript based modal. However, that went against the Hotwire's philosophy of rendering templates on the server. Since I'd need the modal for other, regular Hotwire features anyway, it didn't make sense to duplicate the HTML for the modal, once in JavaScript and once in ERB.

The other solution that would let me keep the modal template on the server was to receive the HTML response containing the URL, and use JavaScript to swap the existing empty modal with this response. But this sounded like a messy solution that would create more problems in future.

Then I tried to think about the problem from the first principles. Since Turbo is a JavaScript library, it must be using JavaScript to process the received Turbo Stream HTML. I just needed to find where and how it was doing it.

So I spent some time digging into the source code, and came across the renderStreamMessage method, which Turbo uses internally to process the stream messages.

Turns out, it is exposed to the public, and also documented on the official site.

Only if I had scrolled down a little more (-‸ლ)

If you need to process stream actions in your Stimulus controllers, use the Turbo.renderStreamMessage(streamActionHTML).

That's it. You don't have to muck around with the DOM, let Turbo take care of that!

So here's what I did. Instead of sending the image URL via a JSON response back to the client, I created a new Turbo Stream template called upload.turbo_stream.erb and wrapped the image URL in a Turbo Stream response as follows:

<%= turbo_stream.replace "modal" do %>
  <div id="modal" class="">
    <%= @image_url %>
<% end %>

Note: To build the Turbo Stream, I'm using helper provided by turbo-rails.

After that, all I needed was to update my fetch call to receive HTML instead of JSON, and call the above method to delegate the DOM manipulation to Turbo.

Here's the resulting uploadFileToServer method.

async uploadFileToServer(file) {
  // ...
  const response = await fetch("/test/upload", {
    // ...
  const html = await response.text()

It worked like magic. The view template for the modal remained on the server where I wanted it to be, and I didn't have to write any extra JavaScript code to update the HTML.

Again, this is the end result:

Pretty cool, right?

Btw, Turbo source code is extremely readable, even more so after dropping Typescript, I highly suggest you give it a read.

Let’s Read the Turbo Source: What Happens When You Click a Link?
Reading the source code is one of the best ways to learn how a feature, framework, or a program works. In this post, we’ll explore the source code of Hotwire’s Turbo library to understand exactly what happens when you click a link. I hope you’ll appreciate Turbo much more after this deep dive.

One more thing: In my research, I also came across the request.js library in Rails which encapsulates the logic to send by default some headers that are required by rails applications like the X-CSRF-Token, and also automatically processes Turbo Stream responses.

Although it would have simplified my code above, I didn't want to add another dependency to my app, so I left it at that.

If you are interested in learning about Hotwire, check out my crash-course on Hotwire's Turbo framework. It's free, and only a preview of what's coming later this year. So stay tuned!

That's a wrap. I hope you found this article helpful and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.