Stimulus.js & HOTWire Tutorial: Interactive Deletes

Interactive websites have that feeling of immediacy. Clicked links respond in milliseconds, and there is never a need to wait… Waiting for a remote record to delete, and then the whole page refresh afterwards can feel like an eternity in web’s modernity. But a little Stimulus works well with Turbo to make items on a page disappear immediately, all while a network request happens in the background. This tutorial takes a remote deletion link, adds an in-place confirmation message, and then hides the element when the delete request is sent to the server. When the request comes back with a success, the element is removed from the page, or with an error, the element put right back in place.

The controller is going to listen for a few different events emitted by Turbo1 when the delete link is clicked. It stops the remote request the first time the link is clicked, and puts a confirmation message in the link. This feels better than the usual full alert modal that adding confirmation message usually throws up. Clicking the link a second time lets the request go back to the server. The element’s style is then set to display:none; so that it disappears immediately. A successful Turbo Stream response removes the element entirely, and an unsuccessful response reverts the style, so the element appears again. A timeout is added after the first click to reset the state of the controller and the link if it isn’t clicked a second time.

The HTML

I’m working with a simple ActiveRecord Post model which has a title, author, and text. The controller is similar to what you could generate from the Rails scaffold command. On the index.html.erb view, this is the HTML:

  <h1 >All Posts</h1>
  <%= link_to "New Post", new_post_path %>
  <% @posts.each do |post| %>
    <div id="<%= dom_id post %>"
        data-controller="delete"
        data-delete-message-value="<strong>Are you sure?</strong>">
      <p>
        <h2><%= link_to post.title, post %></h2>
        <strong><%= post.author %></strong>
        <br />
        <em><%= post.created_at.strftime("%l:%M %P") %></em>
        <br />
        <%= link_to "Delete", post_path(post), data: { 'turbo-method': "delete", 'delete-target': "link", action: "turbo:click->delete#captureClick turbo:before-fetch-request@window->delete#deleteAndHide" } %>
      </p>
    </div>
  <% end %>


There is a div element for the delete controller. The delete link towards the bottom is a target of the delete controller, since the controller needs to use it when getting click events and manipulate the message on it. The controller listens for the turbo:click and the turbo:before-fetch-request events so that the controller can add our visual interactivity. The controller doesn’t need to change anything else with how Rails sends and receives the delete request, meaning less work for the Stimulus controller and fewer chances for bugs.

The Stimulus Controller

The delete_controller.js Stimulus controller is the component in charge of the delete actions. It keeps track of the click state, changes the link text, and handles the result of the server’s response.

On connect(), the controller sets its delete state to false. This dictates later on how the click handler behaves. It sets a value called clickedController to false. This is used to keep state between the turbo:click event and the turbo:before-fetch-request.

In Turbo, the click event allows Javascript to cancel a Turbo request, and have the browser send the request normally. The controller uses it know that a click has happened, because the the turbo:before-fetch-request event is called on the document, and that’s the event similar to the UJS ajax:success event that the controller will use to cancel the request and change the confirmation message.

On a call to deleteAndHide(event) when delete is false, the controller records the current delete link text, changes the delete link to a new confirmation message, sets a reset timeout, and then stops the click event. On a call to deleteAndHide(event) when delete is true, the controller hides its element, and adds listeners for the server response. The response proceeds back to the server as usual.

The handleResponse(event) method resets the element back to the way the element looked, and makes the element visible again. It doesn’t do anything on success, because Turbo will remove it based on the server response.

The resetState() method removes the event listeners, sets the delete link back to the original test, and resets the delete state back to false.

const RESET_TIMEOUT_MILLIS = 3000;

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["link"];
  static values = { message: String };

  connect() {
    this.delete = false;
    this.clickedController = false;
  }

  async captureClick(event) {
    this.clickedController = this.linkTarget == event.target;
  }

  async deleteAndHide(event) {
    if (this.clickedController) {
      this.clickedController = false;
      if (this.delete) {
        this.element.style = "display: none;";
        document.addEventListener(
          "turbo:before-fetch-response",
          this.handleResponse.bind(this)
        );
      } else {
        this.oldMessage = this.linkTarget.innerHTML;
        this.linkTarget.innerHTML = this.messageValue;
        this.delete = true;

        this.timeout = setTimeout(() => {
          this.resetState();
        }, RESET_TIMEOUT_MILLIS);
        event.preventDefault();
        return false;
      }
    }
  }

  handleResponse(event) {
    clearTimeout(this.timeout);
    this.resetState();
    console.log(this);
    console.log(this.element);
    if (event.detail.fetchResponse.response.status != 204) {
      this.element.style = "";
    }
  }

  resetState() {
    if (this.delete) {
      document.removeEventListener(
        "turbo:before-fetch-response",
        this.handleResponse.bind(this)
      );
      this.linkTarget.innerHTML = this.oldMessage;
      this.delete = false;
    }
  }
}

Ruby Controller

The delete method on the rails controller needs to respond to the turbo stream format. This is accomplished by adding format.turbo_stream. It’s using a nifty inline render of the turbo_stream remove command, so we don’t need to write an ERB partial.

  def destroy
    @post.destroy

    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@post) }
    end
  end

Practice?

Try to add something to alert the deleter on the page when the deletion fails. It can assume that failure isn’t a likely case, so the error message should be very disruptive to the page flow.

Interactivity

By making actions on a web page feel immediate, web app users feel more confident that the app registered their wishes. Deletion is definitely a good spot for double checking about someone’s intention, but by changing the delete link text, instead of an alert, it’s not as disruptive as usual. Imagine having to delete hundreds of records, and having to move the cursor multiple times for each action, and you’ll see this method is better for everyone.

Want To Learn More?

Try out some more of my Stimulus.js Tutorials. This is an update of previous tutorial that used Rails UJS, Stimulus.js Tutorial: Interactive Deletes with Rails UJS.

  1. https://turbo.hotwired.dev/reference/events ↩︎

Leave a Reply