How to Properly Structure Stimulus Controller

Abstract image of a conductor in a minimalist, 3D-like style, surrounded by summer colors and bathed in the serene light of an afternoon sun, conveying a sense of tranquility and lightness.

Over the years many articles have been written about organizing and structuring your Ruby (on Rails) code. Many Rails developers hold some (very) strong opinions on the best way to go about it.

But when it comes to JavaScript it’s a lot quieter in the Ruby on Rails Blogosphere, because JavaScript: “yuck”, “meeh”, “no good”, “run!”, or “painful”. But I’m here to tell you that JavaScript has become a pretty good language that’s quite a joy to write.

So I wanted to share some guidelines/tips to help you write better Stimulus controllers (which makes the little bit JavaScript you have to write even more joyous). This is unlikely the-best-way™, but it’s a way that helps you keep your Stimulus controller consistent. This takes away some decisions and helps the future-you debug or change the code.

Keep public functions to a minimum

Keeping the public API minimal is a rule (of thumb) many Ruby developers know. You can use the same rule in your Stimulus controllers. So next to the lifecycle methods initialize(), connect() and disconnect(), only add the functions for the actions that you create (ie. those that are fired based on event listeners, eg. data-action="click->tooltip#show").

Make non-public functions truly private

With Ruby you can make methods private by placing them below the private keyword. In JavaScript you can do the same. By using the #-symbol before the function. When you prefix a function or property name with # in a Stimulus controller (or any JavaScript class), it becomes accessible only within that class’s methods and cannot be accessed from outside the class.

#setupTooltip() {
  // logic here
}

You can then invoke this function anywhere in your Stimulus controller like so:

show() {
  this.#setupTooltip();
}

Use getters to set or transform certain values

In a Stimulus controller (and any JavaScript class) a getter, allows a computed property to be defined that is dynamically generated each time it is accessed. It doesn’t take any arguments and you call them similar to properties, like so: this.#validatedPositionValue.

I like to use getters. Mostly to validate if a certain value is passed correctly, like this:

export default class extends Controller {
  // …
  static values = {position: {type: String, default: "top"}};
  // …

  get #validatedPositionValue() {
    if (["top", "right", "bottom", "left"].includes(this.positionValue)) {
      return this.positionValue;
    } else {
      console.error("Invalid position value");

      return "top";
    }
  }
  // …
}

Instead of using this.positionValue I now use this.validatedPositionValue.

Keep the order for all functions consistent

You don’t need to copy this exact same order, but what ís important, is that you stick to a consistent order for every Stimulus controller. So why not just copy this one—yet another thing not to think about!

// Lifecycle functions first
initialize() // optional, if needed

connect() // optional, if needed

disconnect() // optional, if needed

// Public functions, anything that is called with the event listeners within `data-action`'s
show()

hide()

// The following line is purely for visual purposes, but it is
// similar how Ruby classes work, and that is why I add it
// private

// Change callbacks
valueNameChanged()

// All functions that are called by the public functions (by order of being called)
#privateFunction()

// Functions that are called by the private functions
#privateDetailFunction()

// Lastly all `getters` and `setters`
get #someSetting()

Example

And finally, let’s look at a real component from Rails Designer. I’ve only kept the essential bits to highlight how all above guidelines are used in a real Stimulus controller.

import { Controller } from "@hotwired/stimulus";
import { computePosition } from "./helpers/position_computings";

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

  disconnect() {
    this.tooltipTarget.remove();
  }

  show() {
    this.#computePosition();

    this.tooltipTarget.removeAttribute("hidden");
  }

  hide() {
    this.tooltipTarget.setAttribute("hidden", true);
  }

  // private

  contentValueChanged() {
    // logic when `this.contentValue` changes
  }

  #computePosition() {
    computePosition(this.element, this.tooltipTarget, {
      placement: this.#validatedPositionValue
    });
  }

  get #validatedPositionValue() {
    if (this.#allowedPositions.includes(this.positionValue)) {
      return this.positionValue;
    } else {
      console.error(`Invalid position value.`);

      return "top";
    }
  }

  get #allowedPositions() {
    return ["top", "top-start", "top-end", "right", "right-start", "right-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end"];
  }
}
Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Last updated at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built for Ruby on Rails (inc. Rails 8)

  • Designed with Tailwind CSS and Enhanced with Hotwire

  • Updates for 12 months