Rails 7: Stimulus JS Basics

Stimulus JS Basics. When what you’re building is Rails with light interactions, marketing pages, landing pages, and dashboard apps on Rails 7, Stimulus JS is exactly what you want. It gets out of your way just the right amount, making the easy easy and the hard possible.

This introduction is a brief alternative to the very good Stimulus Handbook, which covers several topics not covered here in-depth.

This post offers a brief introduction and then I will expound upon my thoughts (rant) on the evolution of Rails and Stimulus vs. React

To get a hands-on dive-in lesson where you will build something with Stimulus right away, try the Rails 7: Stimulus JS Basics with ImportMap-Rails instead.

Stimulus calls itself a “modest” framework and in doing so it tries to separate itself from the implementation-heavy likes of React, Vue, Ember, and others. In particular, Stimulus JS is heavy on data-in-the-DOM, or “DOM decoration.” That means instead of taking control of the view away from HTML like React (because our application logic lives in a managed state of a big JS app), we’re going to leverage HTML for what it is good at: Structured data.

Stimulus has one core concept: The Stimulus Controller. Unlike Rails controllers, these object exist in a formal sense more like behaviors. The primary difference between a Stimulus controller and a Rails controller is that a Rails controller has only one instance within the context of a web request. A Stimulus controller, on the other hand, is more like a lightweight Javascript decorator or behavior attached to a part of your page (DOM element).

You may choose to decorate one part of your HTML with a Stimulus controller, or you can use the same controller in more than one place and it will create two instances.

You will decorate your page using the data-controller attribute, and then pass the controller name with hyphens (not underscores) between the words of any multi-word controller.

Confirm that Stimulus JS Works

Uncomment this line in routes.rb

Rails.application.routes.draw do
  root "articles#index"
end

Generate an articles controller

rails generate controller Articles

Now create a file at app/views/articles/index.erb

<div data-controller="hello"></div>

Rails has come prepackaged with a Stimulus controller. Open up app/javascript/controllers/hello_controller.js to examine its contents.





import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.textContent = "Hello World!"
  }
}

TROUBLESHOOTING

• Make sure that the gem 'stimulus-rails' is in your Gemfile and you bundle install.

Be sure that import "./controllers" appears in your application.js file. It should refer to the app/javascripts/controllers folder, but if your application.js file is inside of a packs/ folder (as with Webpack or Shakapacker React on Rails setup), it will be relative to the location of your application.js file.

• For the latest version of the stimulus, be sure that the top of your Stimulus controllers import this:

import "@hotwired/turbo-rails"

If you are using Stimulus without the rest of turbo-rails, instead:

import { Controller } from "@hotwired/stimulus"

Older versions of Stimulus allowed you import from "stimulus" (without the @hotwired/), but the latest version will hiccup if you do this.

• If you are adding Stimulus to an existing app, be sure to run yarn add @hotwire/stimulus or yarn upgrade @hotwire/stimulus to update to the latest version.

Stimulus Crash Course

Stimulus is a lightweight Javascript framework that connects behaviors (controllers) around parts of your HTML. You will render content on the server, not on the client, and use Stimulus to lightly decorate behaviors onto HTML.

Loading Strategies

There are two strategies for loading your Stimulus controllers, configured in javascript/controllers/index.js

Strategy #1: Eager Loading

Eager loading is what you will start with if you have started with a Rails 7 ImportMap app (did not use the --js or --css flag when creating your app). In this case, your Stimulus controller index does not need updating each time you add or rename a controller, as all the controller names are eagerly loaded automatically.

This configuration is good for lightweight Rails apps that prefer Importmap and not a JS build tool (JSBundling or Shakapacker).

Strategy #2: Explicit Loading

If you started with a bundled app (–js or –css) then you must explicitly load each Stimulus controller in your controllers/index.js file. Explicitly means each one is listed out onto its own line of code.

When using explicit loading, all Stimulus controllers must be registered in the file app/javascript/controllers/index.js

You can update this file one of two ways:

1.

When generating a new controller using the Rails helper:

./bin/rails generate stimulus controllerName

When using the generator, the index file will automatically be modifying the manifest file.

2.

If you create the Stimulus controller by hand, you must this command to update your manifest file:

./bin/rails stimulus:manifest:update

Meet Stimulus JS

Your Rails app now comes with an app/javascript/controllers where your Stimulus controllers will live.

Stimulus JS new folder structure

Take a look at app/javascript/controllers/application.js now

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus = application

export { application }

Now take a look at the default hello_controller.js, which is provided as an example only. Add the line shown in orange.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    console.log("Stimulus connected the Hello controller"); 
    this.element.textContent = "Hello World!"
  }
}

The next thing to look at is app/javascript/controllers/index.js

// Import and register all your controllers from the importmap under controllers/*

import { application } from "controllers/application"

// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
// lazyLoadControllersFrom("controllers", application)

Here, we are automatically eager loading every file in the javascript/controllers folder that ends with the text _controller. Make sure that all your file names end with _controller.js

As well, because of ES Module default exports, note that every controller is exported as the name of the file itself, even though you don’t actually specify the object with an explicit name. When you have a two word controller name (or more), your want to use underscores in the file name and dashes (hyphens) when decorating. In this example, the controller is only the single word “hello.” We’ll take a look at two-word controller names below.

Wire Up a Stimulus JS Controller

Add to any page:

<div data-controller="hello"></div>

When you load your Rails app (rails s), examine your web console and confirm you see the message we added as console.log above. If you do, you have Stimulus wired up correctly.

You’re now ready to learn about defining targets, actions, and values.

app/javascript/controllers/the_chooser_controller.js

import { Controller } from "@hotwired/stimulus"
 export default class extends Controller {
   static targets = ["message"]
 connect() {
 }
}

app/views/articles/index.erb

<div data-controller="the-chooser">
  this is the hello controller
  <p data-the-chooser-target="message">

  </p>
</div>

Underscore vs. Dash Syntax vs. camelCase

(For two-or-more-word Controller Names)

• Although the JS objects become camelCase in Javascript, the camelCase naming is all but hidden from you the developer, except for the case of defining your targets, when you will to be sure to use camelCase in the Stimulus controller.

• When naming the file use underscores (e.g. the_chooser.js)

• In the view, you will always use dash syntax
When defining methods to invoke in actions it is common to use camelCase in JS, which can be confusing.

• When referencing targets or outlets from stimulus controllers, you’ll use titleCase for targets and snake-case for outlets.


Notice that the file name of the controller takes underscores but the controller name when referenced from the view takes dashes.

app/javascript/controllers/the_chooser_controller.js

import { Controller } from "@hotwired/stimulus"
 export default class extends Controller {
   static targets = ["message"]
 connect() {
 }
}

app/views/articles/index.erb

<div data-controller="the-chooser">
  this is the hello controller
  <p data-the-chooser-target="message">

  </p>
</div>
Stimulus JS filename syntax

The .element Attribute

The elements attribute gives you direct access to the HTML element that the Stimulus is wrapping.

  • Unadulterated access to the DOM itself. 
  • Does not mess around. You have direct access to the primary HTML element of the controller.

Targets

connect the controller to DOM elements.

  • Wire up this controller to sub-elements of the primary element.
  • Targets work with a special data- attribute that is named with the controller name and the target name

Target Example #1

app/javascript/controllers/the_chooser_controller.js

<div data-controller="the-chooser">
  this is the hello controller
  <p data-the-chooser-target="message">

  </p>

  <button data-action="click->the-chooser#buttonClickHandler">
    Click me
  </button>
</div>

app/views/articles/index.erb

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="articles"
export default class extends Controller {
  static targets = ["message"]

  connect() {

  }

  buttonClickHandler () {
    console.log("buttonClickHandler was called....")
    this.messageTarget.innerHTML = "Your message goes here"
  }
}

Target Example #2

Let’s consider a second basic target naming example.

Let’s consider a second basic target naming example. When Stimulus loads your controller class, it looks for target name strings in a static array called targets.In example #1 above, the target’s name was just “message” so example #2 shows specifically how camelCase and dash-casing work for two-word target and controller names.

In this example, we will have a target “abcXyz“. Notice that this target name has two words.

import { Controller } from "stimulus"

// Connects to data-controller="articles"
export default class extends Controller {
  static targets = [ "abcXyz" ]

  connect() {
    this.abcXyzTarget.classList.add("pop")
  }
}


Markup:

Our template (view) that contains this target will identify the target using

data-(controller name)-target="(target name)"

Usage:

data-articles-target="abcXyz"

Notice that In the Javascript, your target name should be camelCase and it should also be camelCase in the HTML markup, which you see above. You’ll always want target names in camel case.

Target Example #3

Ok, let’st do a third target naming example. In this case, Let’s say we have a controller called dateSpanner and three targets named startDate, endEnd and, resultsDisplay. Notice that both the controller name and all of the target names have two words.

When declaring them, be sure to use camelCase for your target names here in your Stimulus controlllers:

app/javascripts/controllers/date_spanner_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="date-spanner"
export default class extends Controller {
  static targets = ["startDate", "endEnd", "resultsDisplay"]
  ...
}

This example is from the companion lesson to this blog post, Rails 7: Stimulus JS Basics with ImportMap-Rails which is more of a dive-in tutorial that will introduce you to the same Stimulus concepts.

Magic Target Properties

For each target name in the array, Stimulus adds three new properties to your controller. Here, our "abcXyz" target can now be referenced by the following properties:

  • this.abcXyzTarget evaluates to the first abcXyz target in your controller’s scope. If there is no abcXyz target, accessing the property throws an error.
  • this.abcXyzTargets evaluates to an array of all abcXyz targets in the controller’s scope.
  • this.hasAbcXyzTarget evaluates to true if there is a abcXyz target or false if not.

Notice that for the data attribute we use data-, the name of the controller, and -target. Here, always use dashes.

For the target name itself, there is no dash-to-camelCase conversion, so for two-word targets refer to the target by camelCase in the target name in the view (above) and also in the javascript (below).

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "abcXyz" ]
  
  connect() {
    console.log("abcXyzTarget is", this.abcXyzTarget);
    this.abcXyzTarget.classListadd("disabled");
  }
}

Actions

connect the controller to DOM events

Actions Example #1: In this example, we’ll define a target called message and an action called buttonClickHandler.

They are declared in the view like so:

'data-action' => "(HTML action)->(controller name)#(controller method to call)"

app/javascript/controllers/the_chooser_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["message"]

  connect() {

  }

  buttonClickHandler () {
    console.log("buttonClickHandler....")
    this.messageTarget.innerHTML = "Your message goes here"
  }
}

app/views/articles/index.erb

<div data-controller="the-chooser">
  this is the hello controller
  <p data-the-chooser-target="message">

  </p>

  <button data-action="click->the-chooser#buttonClickHandler">Click me</button>
</div>

• The filename of the Stimulus controller matches the dash name of data-controller

The data action attribute is event->controller-name#handlerToInvoke

Actions Example #2

'data-action' => "(HTML action)->(controller name)#(controller method to call)"

In this context, we are defining an action for click for the know-nothing controller that is hooked to the playVideo function. The controller name here is now hyphenized (abc-def), but the method name should be camelCase.

example:

'data-action' => "click->know-nothing#playVideo

Values

read & write the data attributes of controller’s element

Values let you access data attributes on your controller object.

Declare: To set up a value, you will declare them at the top of your controller:

static values = {
thingId: String,
age: Number,
}

Markup:

Then to wire it up, you’ll use both the controller name and the value name.

data-(controller name)-(value name)-value="..."

Here, just as with targets, the controller name is hyphenized and the value name is also hyphenized in the markup but camelCase in the Javascript. The … of the data attribute is the value itself you want to preserve for your controller.

Connect:

You will need one extra step to use your value: declare it in your connect:

connect() {
  fetch(this.thingIdValue).then( )
}

Usage:

Now, whenever you are in your wired-up Stimulus controller, you can use this.thingIdValue directly in your code.

Outlets

Example 1: An Apple has an outlet to a Banana

Here, an apple controller has an outlet for a banana.

app/views/welcome/index.html

Hello World
<div data-controller="apple"
     class="badge badge-primary"
     style="border: solid 2px red;"
     data-apple-banana-outlet=".sweet-thing">
  Apple
  <button data-apple-target="clickMe">Click Me</button>
</div>

<br />

<div data-controller="banana"
     class="sweet-thing badge badge-secondary"
     style="border: solid 2px green;">
  Banana
</div>

app/javascript/controllers/apple_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="apple"
export default class extends Controller {

  static outlets = ["banana"]
  static targets = ["clickMe"]
  connect() {
    this.clickMeTarget.addEventListener('click', () => {
      this.bananaOutlet.alertUser();
    })
  }
}

app/javascript/controllers/banana_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="banana"
export default class extends Controller {
  connect() {
  }

  alertUser() {
    this.element.classList << ["alert alert-primary"]
    this.element.style.margin = "20px";
    this.element.style.backgroundColor = "green";
    alert("Hello from the Banana Controller")
  }
}

Example 2: Two and Three-Word Controller & Outlet Names

As with targets and actions, be sure to pay close attention when you have two or three-word outlet names or controller names. Specifically, notice that two-word outlets when defined in the Stimulus controller should use snake-case (not titleCase, which is contrary to how targets are defined, where you will use titleCase in the Stimulus controller).

Using Stimulus JS With Typescript

If you define targets in a Typescript Stimulus controller, Typescript doesn’t know about the dynamically-added methods created automatically by Stimulus. If you try this, you’ll get a Typescript error like the one shown above.

Unfortunately, you have to declare them explicitly like so:

  static targets = [ "startButton" ]
  declare readonly hasStartButtonTarget: boolean
  declare readonly startButtonTarget: HTMLInputElement
  declare readonly startButtonTargets: HTMLInputElement[]

You only have to declare the extra functions that you actually reference in your code, but you will need to declare them for all targets.

How it Fits With Rails

Stimulus is a DOM-friendly paradigm so you can rely on data attributes. As well, remember that you can define plain old HTML form elements, wrap them in Stimulus controllers, and then have the Stimulus controllers manipulate the form elements. You can insert hidden fields, remove hidden fields, or add or remove elements from your form.

This pattern works great, for example, for an in-page manipulation that is to happen before a form submission (now, with Rails 7 Turbo, a Turbo form submission.)

Keep your data in your DOM! When you want to submit forms, submit forms and let Turbo do its magic. For everything else, there’s Stimulus.

Stimulus demonstrates that most web apps don’t really need heavy JS paradigms. While the allure of React is sexy to a certain sect of Silicon Valley, it hasn’t produced the level of success for new ventures that Rails has.

Also, by not having benefitted from the 17 years of conceptual compression, the Node ecosystem has somewhat spiraled out of control with many options. Many good ones, no doubt. But they say road to hell is paved with good intentions.

What is so promising about Stimulus JS and Rails 7 is not that it is somehow more advanced Javascript— quite the opposite.

A keen observer might look at the web apps we are building today in 2022 and think, “How are these apps different than the apps of 2012?”

In truth, they aren’t actually that different.

Sure, our browsers can do WebRTC now (video chatting), and HTTP2 has made things faster, and we may have a few more features in the web browser (like HTML5 native date pickers).

But fundamentally, from the user’s perspective, very little has actually changed in the last 10 years.

From the programmer’s perspective— what a time to be alive! (to quote DHH). The last 10 years saw the rise of Github and Shopify (both two of Rails’ biggest success stories), a consolidation of Big Tech, major shifts in the understanding of Javascript (and now Typescript), nay, even programming itself— in some ways.

From a backend vs frontend perspective what has happened over the last 10 years makes total sense to me: Rails and Java, the primary server-based backend champions, are highly successful at simply being object oriented.

Writing a UI with objects is a disorienting and dizzying experience. That’s because fundamentally, UI and UX follows rules that are user centric and not business centric.

So when the advent of modern product development came about in the aughts (2000s), it made sense that the first attempts by programmers were to try to build UIs using objects.

What does this all have to do with Stimulus?


Nothing really, but it demonstrates how lost we are as an industry. Writing a large UI in a functional programming (FP) paradigm — like React — will cost you a lot in terms of how much time you spend writing code dealing with state management. The Rails-Stimulus philosophy says: Most people don’t need it.

So Stimulus may just the bridge we need. The browser is just fine, we’re good, we can do “80%” of what we need with just lightweight JS objects (like Stimulus JS bridged with other JS objects you can build by hand) and basic HTML decoration using data- attributes. (I think personally think the number is closer to 95% or 98%.)

Would I build a frontend data-heavy application with Stimulus? Probably not.

What most people don’t realize is that the web is no longer a place where people have high expectations.

Will React — or any other new JS paradigm’s promise of newness — produce a better result? A more profitable one? A result that is better for the user, the human, the planet? A result that is better for the business?

Nobody cares if your app was written using functions or objects.

Successful websites are the ones that load very fast, don’t have bugs, have seamless user interactions, clean UX, provide something new while also giving the feeling of old. (What Derek Thompson calls in his book Hit Makers, the “MAYA principle”— most advanced yet acceptable.)

That’s the stuff of great software. In order to make that above happen, you need great automated testing and a solid deployment pipeline.

Can you build all of that in the Node ecosystem? Of course, you can, but in the Node-React worlds, it costs a lot more, has even more choice overload, less cultural stringency around automated testing, and has little industry-wide agreement about quality standards.

Unlike mobile development, where the new phones seem to be able to do things we didn’t even dream of in the past, the web browsers just aren’t functionally evolving very much anymore.

You could put all the work in the world into building an efficient state-managed system in React but it wouldn’t make a difference: Your website will probably just load slowly (which as of 2022 will start hurting your Google rankings even more than it used to in the past) and likely be difficult to keep consistently tested.

Most web apps of the future will be light interactions, dashboard apps, marketing pages, landing pages, video streaming portals, etc. In these cases, Stimulus is just what is called for, no more no less.