DEV Community

Cover image for How to Properly Structure Stimulus Controller
Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

How to Properly Structure Stimulus Controller

This article was previously published on Rails Designer.


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”, “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 lay out 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 á 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
}
Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
  // …
}
Enter fullscreen mode Exit fullscreen mode

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

// 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()
Enter fullscreen mode Exit fullscreen mode

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

  #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"];
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)