Triggering Hotwire TurboStream Action with request.js

There are many times when a user’s action requires updating a page without reloading it. In the past, this often meant writing custom JavaScript, but in Rails 7, it’s possible to use Hotwire and a bit of Stimulus.js to make this process easier and more efficient.

In this article, we’ll tackle down a scenario that I faced: triggering a controller action via a button or link, while also updating the DOM dynamically.

Note: You’ll need some basic knowledge of Stimulus and Hotwire to follow along.

Problem

I encountered this problem while working on an edit form in my Rails app, where users could upload and replace images (such as logos or profile pictures), with ActiveStorage, but they couldn’t delete them.

A link to delete the image on the go, without leaving or submitting the form

I wanted to give users the ability to delete images instantly. We could have used a checkbox kinda-thing, labeled “Delete image,” which would only take effect when the form was submitted. However, to keep this article simple, we went with the solution that allowed us to reuse logic the most. With the checkbox, you should have added an extra parameter to every controller that processed an image.

Instead, I implemented a button that allows users to delete the image on the fly. When clicked, the button purges the image from the database and displays a success message — providing a more modern and dynamic experience.

However, while coding, I realized that since the button was inside an existing form, using a regular button with a POST action would submit the entire form. That’s when I realized I was going to need Javascript to delete the image without affecting the rest of the form. request.js was the perfect tool for this, as it simplifies the request-making process in javascript.

Disclaimer: This form was intended for use by a single user (the admin), and, as I said, we wanted to keep this article simple. If the form were to be used by a broader audience, I would have prioritized a more user-friendly approach. A button that alters functionality before the form is submitted is not an ideal UX solution.

Solution

To keep my controllers DRY, I created a dedicated ImageRemovalsController. This allows me to reuse the image removal logic across my app, rather than duplicating a remove_image method in multiple controllers.

class ImageRemovalsController < BaseController
before_action :set_image, only: [:create]

def create
@image.purge
end

private

def set_image
@id = params[:image_id]
@image = ActiveStorage::Attachment.find(@id)
end
end

Next, we’ll need a link to trigger the image deletion. However, the request itself won’t be made directly from the link — it will be handled by the Stimulus controller. First, we define the link:

link_to 'Delete image', '#'

Now, we link it to the Stimulus controller and set the data-action attribute. We’ll pass the image’s ID to the controller, which we retrieve from the form’s object, since we’re working within an edit form.

link_to 'Delete image', '#',
data: {
controller: 'image-removal',
image_removal_image_id_value: f.object.image.id,
action: 'click->image-removal#removeImage',
},
class: 'text-red-500 hover:text-red-700'

Now let’s move on to the controller. It’s straightforward: we need to hit the image_removals#create endpoint, which is defined as a POST request. We’ll use request.js for this, and we’ll format the request to match the route, passing the image_id as a parameter and specifying turbo-stream as the response format.

import { Controller } from "@hotwired/stimulus"
import { post } from '@rails/request.js'
export default class extends Controller {
static values = {
imageId: Number,
}

removeImage() {
post(
`/image_removals?image_id=${this.imageIdValue}`,
{ headers: { 'Accept': 'text/vnd.turbo-stream.html, text/html' } }
)
}
}

Lastly, this method will trigger a Turbo Stream response, which will both remove the image from the form and display a success or error message.

Keeping in mind that the element that contains the image has the same id as the image:

<% if f.object.image.attached? %>
<div id="<%= f.object.image.id %>">
<%= remove_image_btn(f.object.image.id) %>
</div>
<% end %>

Here’s the view file for image_removals/create.turbo_stream.erb, which will destroy that element using the image’s id.

<% if @image.destroyed? %>
<%= turbo_stream.remove(@id) %>
<%= turbo_stream.toast('Image removed successfully') %>
<% else %>
<%= turbo_stream.toast('There was an error when removing the image', type: 'alert') %>
<% end %>

Notice that, as the image is already destroyed, we defined the @id variable in the ImageRemovals controller, in the set_image method:

def set_image
@id = params[:image_id]
@image = ActiveStorage::Attachment.find(@id)
end