DEV Community

Cover image for Hotwire: best practices for stimulus
Pete Hawkins
Pete Hawkins

Posted on • Updated on

Hotwire: best practices for stimulus

From my experience building several production apps with Hotwire, Turbo frames and Turbo streams handle the bulk of things you need to build an interactive web application.

You will however, definitely need a little JavaScript sprinkles from Stimulus.

I want to run through all of the stimulus controllers included in Happi and talk about some ‘Best Practices’ from what I have learnt so far.

The first controller you’ll write

In every Hotwire app I’ve built so far, the first controller I end up needing is ToggleController. This is usually when I set up my Tailwind UI layout and need to start hiding and showing nav menus.

ToggleController

As you’ll see below, I am importing useClickOutside from stimulus-use, it’s a great library with small, composable helpers, I urge you to check it out!

The other thing I like to do here is leave some usage comments, it makes it a lot easier to peep into the controller and see how things work and what data attributes I need to add to my HTML.

import { Controller } from "@hotwired/stimulus";
import { useClickOutside } from "stimulus-use";

/*
 * Usage
 * =====
 *
 * add data-controller="toggle" to common ancestor
 *
 * Action (add this to your button):
 * data-action="toggle#toggle"
 *
 * Targets (add this to the item to be shown/hidden):
 * data-toggle-target="toggleable" data-css-class="class-to-toggle"
 *
 */
export default class extends Controller {
  static targets = ["toggleable"];

  connect() {
    // Any clicks outside the controller’s element can 
    // be setup to either add a 'hidden' class or 
    // remove a 'open' class etc.
    useClickOutside(this);
  }

  toggle(e) {
    e.preventDefault();

    this.toggleableTargets.forEach((target) => {
      target.classList.toggle(target.dataset.cssClass);
    });
  }

  clickOutside(event) {
    if (this.data.get("clickOutside") === "add") {
      this.toggleableTargets.forEach((target) => {
        target.classList.add(target.dataset.cssClass);
      });
    } else if (this.data.get("clickOutside") === "remove") {
      this.toggleableTargets.forEach((target) => {
        target.classList.remove(target.dataset.cssClass);
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The biggest thing I can stress is to make your controllers as generic as possible. I could have made this controller NavbarController and then it would only toggle a navbar. Because this is generic, I have reached for it so many times in my app and been able to reuse it.

AutoSubmitController

import { Controller } from "@hotwired/stimulus";
import Rails from "@rails/ujs";

/*
 * Usage
 * =====
 *
 * add data-controller="auto-submit" to your <form> element
 *
 * Action (add this to a <select> field):
 * data-action="change->auto-submit#submit"
 *
 */
export default class extends Controller {
  submit() {
    Rails.fire(this.element, "submit");
  }
}
Enter fullscreen mode Exit fullscreen mode

This one is tiny, I needed it to auto submit a form when these dropdowns are changed, to go ahead and save changes. Again, I’ve kept it generic, so it could be reused in other places that require similar behaviour.

Auto submit dropdowns

DisplayEmptyController

Happi empty state

This one is super handy, it allows the empty state to work properly with Turbo Streams. Without it, when Turbo streams push new messages onto the screen, the UI showing ‘You don’t have any messages’ would still be visible and everything would look broken.

It also relies on stimulus-use’s useMutation hook, which means it just workstm with Turbo streams and we don’t need any complex callbacks and still don’t need to reach for custom ActionCable messages.

import { Controller } from "@hotwired/stimulus";
import { useMutation } from "stimulus-use";

/*
 * Usage
 * =====
 *
 * add data-controller="display-empty" to common ancestor
 *
 * Classes:
 * data-display-empty-hide-class="hidden"
 *
 * Targets:
 * data-display-empty-target="emptyMessage"
 * data-display-empty-target="list"
 *
 */
export default class extends Controller {
  static targets = ["list", "emptyMessage"];
  static classes = ["hide"];

  connect() {
    useMutation(this, {
      element: this.listTarget,
      childList: true,
    });
  }

  mutate(entries) {
    for (const mutation of entries) {
      if (mutation.type === "childList") {
        if (this.listTarget.children.length > 0) {
          // hide empty state
          this.emptyMessageTarget.classList.add(this.hideClass);
        } else {
          // show empty state
          this.emptyMessageTarget.classList.remove(this.hideClass);
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

FlashController

This one’s not as generic as I would like, maybe I should call is AutoHideController? It’s pretty straightforward, automatically hide after 3 seconds, but can also be dismissed by clicking the 'X'.

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

/*
 * Usage
 * =====
 *
 * add data-controller="flash" to flash container
 * p.s. you probably also want data-turbo-cache="false"
 *
 * Action (for close cross):
 * data-action="click->flash#dismiss"
 *
 */
export default class extends Controller {
  connect() {
    setTimeout(() => {
      this.hideAlert();
    }, 3000);
  }

  dismiss(event) {
    event.preventDefault();
    event.stopPropagation();

    this.hideAlert();
  }

  hideAlert() {
    this.element.style.display = "none";
  }
}
Enter fullscreen mode Exit fullscreen mode

HovercardController

This one loads in a hovercard, similar to hovering a users avatar on Twitter or GitHub. I originally got this code from Boring Rails, it’s a great resource for all things Rails/stimulus/Hotwire, you should definitely check it out!

Note: If you are planning on using this, bonus points for making it more configurable and using Stimulus CSS classes for the hidden class.

It might also be smart to use the new Rails Request.js library rather than directly using fetch.

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

/*
 * Usage
 * =====
 *
 * add the following to the hoverable area
 * data-controller="hovercard"
 * data-hovercard-url-value="some-url" # Also make sure to `render layout: false`
 * data-action="mouseenter->hovercard#show mouseleave->hovercard#hide"
 *
 * Targets (add to your hovercard that gets loaded in):
 * data-hovercard-target="card"
 *
 */
export default class extends Controller {
  static targets = ["card"];
  static values = { url: String };

  show() {
    if (this.hasCardTarget) {
      this.cardTarget.classList.remove("hidden");
    } else {
      fetch(this.urlValue)
        .then((r) => r.text())
        .then((html) => {
          const fragment = document
            .createRange()
            .createContextualFragment(html);

          this.element.appendChild(fragment);
        });
    }
  }

  hide() {
    if (this.hasCardTarget) {
      this.cardTarget.classList.add("hidden");
    }
  }

  disconnect() {
    if (this.hasCardTarget) {
      this.cardTarget.remove();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

MessageComposerController

This controller is really the only app-specific stimulus controller I’ve written so far, which is pretty remarkable, considering I’ve built a full production quality app, with just a handful of lines of JS, this really shows the power of Hotwire and Turbo.

Happi has canned responses, which help you automate writing common messages. When you click a canned response, this will take its HTML and push it into the action text trix editor.

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

/*
 * Usage
 * =====
 *
 * add this to the messages form:
 * data-controller="message-composer"
 *
 * Action (add this to your snippets):
 * data-action="click->message-composer#snippet" data-html="content..."
 *
 */
export default class extends Controller {
  connect() {
    this.editor = this.element.querySelector("trix-editor").editor;
  }

  snippet(event) {
    this.editor.setSelectedRange([0, 0]);
    this.editor.insertHTML(event.target.dataset.html);
  }
}
Enter fullscreen mode Exit fullscreen mode

NavigationSelectController

Another simple one here, used for responsive navigation on mobile via a select menu.

This is used within the settings page, on large screens, we have tabs down the side and on mobile collapse these into a dropdown that when changed, navigates to another sub-page within settings.

import { Controller } from "@hotwired/stimulus";
import { Turbo } from "@hotwired/turbo-rails";

/*
 * Usage
 * =====
 *
 * add data-controller="navigation-select" to common ancestor
 *
 * Action:
 * data-action="change->navigation-select#change"
 *
 */
export default class extends Controller {
  change(event) {
    const url = event.target.value;
    Turbo.visit(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

SlugifyController

This ones used when creating a team on Happi. You have to pick a custom email address that ends in @prioritysupport.net, to make the UX a bit nicer we want to pre-fill this input with your company name.

Slugify in action

import ApplicationController from "./application_controller";

/*
 * Usage
 * =====
 *
 * add data-controller="slugify" to common ancestor or form tag
 *
 * Action (add to the title input):
 * data-action="slugify#change"
 *
 * Target (add to the slug input):
 * data-slugify-target="slugField"
 *
 */
export default class extends ApplicationController {
  static targets = ["slugField"];

  change(event) {
    const { value } = event.target;
    this.slugFieldTarget.value = value.toLowerCase().replace(/[^a-z0-9]/, "");
  }
}
Enter fullscreen mode Exit fullscreen mode

That’s it!

Yep, a full application with a rich user-interface, live updates with websockets and only 8 JavaScript files to maintain!

What’s even better here, is that 7 of the 8 stimulus controllers can be copied and pasted into other apps, I use a lot of these across different projects.

How to get the most out of Hotwire?

As you can probably tell from all my controllers shown above, my number 1 tip is to keep things generic, try to glean the reusable behaviour when you need functionality, rather than creating specific controllers for specific parts of your application.

Other than that, try to rely on Turbo frames or streams to do the heavy lifting, you should really be avoiding writing stimulus controllers unless absolutely necessary, you can do a lot more with Turbo than you might think.

Finally, check out Better stimulus and Boring Rails for a lot of actionable tips and tricks!

Discussion (1)

Collapse
phawk profile image
Pete Hawkins Author

I’ve been extremely impressed by Hotwire in all the apps I’ve built. Let me know if there’s any specific use cases you aren’t sure how to solve, or slick ways you have done things.