The stimulus.js framework is a lightweight JavaScript framework developed by the big name Basecamp, the company that developed the core Ruby on Rails framework. I’ve heard of the stimulus.js framework for a long time, but I’ve never actually used it. Recently, I just had the opportunity to practice in a small project of my own, and have some experience, so I’d like to share a summary.

Reminder: If you want to quickly experience the demo made by stimulus.js, you can take a look at this todomvc-stimulus.

A restrained front-end JavaScript framework

Speaking of my general impression of the stimulus.js framework, I think it is a very restrained front-end JavaScript framework. It focuses on the binding of HTML elements to JavaScript objects, and the binding is one-way, not the two-way binding that has long been common in front-end development. Beyond that, it offers no additional functionality.

Because of its restraint, lightweight is its inevitable first advantage. Secondly, with its designed concept of controller, it can achieve isolation and decoupling of state in the interaction logic. Finally, the organization of the controller code is familiar to those familiar with Rails development: convention over configuration. Each controller definition needs to follow the convention that a controller corresponds to a file in the controllers directory, with the same name as the controller.

The lightness of Stimulus.js

There are very few core concepts in Stimulus.js, there are only 4 core concepts that you need to understand to get started with the stimulus.js framework.

Controllers

Controllers are JavaScript objects bound to HTML elements that declare data attributes such as data-controller="todos".

1
<div data-controller="todos"></div>

stimulus.js will automatically instantiate the corresponding controller for all such elements, one instance of each. In the example above, stimulus.js automatically looks for the file located in app/javascript/controllers/todos_controller.js and imports the default classes exported there, a classic convention over configuration approach.

1
2
3
4
5
6
7
// app/javascript/controllers/todos_controller.js

import { Controller } from 'stimulus';
export default class extends Controller {
  connect() {
  }
}

Of course, if you don’t want to use or can’t use the agreed form, you can also register the controller explicitly via the functions provided by stimulus.js.

1
application.register("todos", TodosController)

Such elements, and their descendants, are visible to the controller to which the element is bound. That is, in the stimulus.js framework, the controller’s actions can only be applied to the controller-bound element and its descendants. The same principle applies in the case of nested Controllers.

Controllers can collaborate with each other by means of events, which will be added later when we talk about Actions.

Targets

Targets is another way to implement binding between HTML elements and JavaScript objects, but it works under a specific Controller.

1
2
3
4
5
<div data-controller="todos">
    <!-- ... -->

    <button data-todos-target="addBtn">Add</button>
</div>

The rule for declaring the target is data-<controller>-target=<target-name>, and accordingly, the object to be bound needs to be declared in the controller.

1
2
3
4
5
// app/javascript/controllers/todos_controller.js

import { Controller } from 'stimulus';
export default class extends Controller {
  stitic target = ["addBtn"]

Once you have bound the targets, you can use something like this.addBtnTarget or this.addBtnTargets (for cases where multiple HTML elements are bound to the same Target) in the controllers method to access the bound HTML elements, as per the stimulus.js protocol for targets ) to access these bound HTML elements.

Controllers and Targets lifecycle callbacks

The above mentioned Controllers and Targets are binding functions between HTML elements and JavaScript objects, because HTML elements are loaded with the browser and subsequent DOM operations, which brings up the question, what is the lifecycle of these objects?

Controllers and Targets lifecycle callbacks

These lifecycle callback functions are all closely related to changes in the DOM, and generally look at these conditions.

  • Does the element exist?
  • Does the bound identifier exist in the element’s property list, such as data-controller or data-<controller>-target?

The connect type callback is called when the condition is changed from partially or completely not being met to being met; conversely, the disconnect type event callback is called if all conditions are no longer met due to some DOM action.

controllers can define initialization tasks in the connect() callback, such as the initialization of some state of the controller, and accordingly, [name]TargetConnected can be used for the initialization of a target.

Actions

Actions is the event callback mechanism in stimulus.js, similar to the syntax of onclick and onchange in HTML.

1
2
3
4
5
<div data-controller="todos">
    <!-- ... -->

    <button data-todos-target="addBtn" data-action="click->todos#add">Add</button>
</div>

Actions supports multiple event callback declarations, which also facilitates collaboration between Controllers.

1
2
3
4
5
<div data-controller="todos submitter">  <! -- Note that multiple controller bindings are used here -->
    <!-- ... -->

    <button data-todos-target="addBtn" data-action="click->todos#add todos:added->submitter#submit">Add</button>
</div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// app/javascript/controllers/todos_controller.js
import { Controller } from 'stimulus';
export default class extends Controller {
  add() {
    // do something to maintain the status of todos controller
    this.dispatch("added", {detail: {todos: [xxxx]}}}})
  }
}

// app/javascript/controllers/submitter_controller.js
import { Controller } from 'stimulus';
export default class extends Controller {
  submit(event) {
    const todos = event.detail.todos;   // extract event data
    // do something else
  }
}

In this example, the flow of the program execution is as follows.

  1. after the user clicks the Add button, the click event triggered by the button triggers a callback to the added method of the todos controller.
  2. after the added method executes its own core logic, it triggers the todos:added event by calling the this.dispatch method, noting that the todos: prefix here is automatically added by the framework.
  3. the todos:added event is generated, triggering a callback to the submitter controller’s submit method.

In this way, by abstracting different controllers to achieve the separation and decoupling of logic, and then through the event mechanism, the logic will be assembled and orchestrated.

Understanding these 4 core concepts is enough to develop a relatively simple front-end logic with stimulus.js. Of course, there are several other concepts in stimulus.js, but in my opinion they are just icing on the cake, so I don’t need to go into them here.

Also talk about the scenarios where stimulus.js is not suitable

Despite being a small project, there are some issues that I found cumbersome in using stimulus.js.

  1. Lack of encapsulation of DOM operations: Since stimulus.js only provides binding between HTML elements and JavaScript objects, it does not provide encapsulation of DOM operations, so when you need to manipulate the DOM, you often need to use the operations of native DOM objects directly, such as Element.classList .add() type, if it is in the early browser, you still need to worry about compatibility issues, etc., but the good thing is that now the browser compatibility problems have been much less, this is not too big a problem.
  2. Lack of front-end rendering support: Because the binding in stimulus.js is not bidirectional, in some cases where you need to render different page content or visual effects based on JavaScript objects, without the support of other frameworks, you have to write various string interpolations and Element.innerHTML = xxxx, which is also inefficient.

So, to sum up, if your front-end page is a heavy interaction page, it might not be a wise choice to use only stimulus.js. If I choose to use stimulus.js, I think it’s more comfortable to use stimulus.js if it’s a content-based light interaction scenario, such as blogs or forums, where the general interaction is the comment section, simple text input and additional display, etc.; but in other cases, I would probably go directly to vue.js or other more comprehensive framework to minimize the code that synchronizes state between the page and the logic.

Some things to keep in mind when using stimulus.js

  1. Action outside the scope of Controllers cannot call back to the Controller’s methods. This problem took some time to solve, but I didn’t understand why, then I realized that it was because I didn’t pay attention to the Controller scope. Because my action declares that the controller that needs to be called back is not visible in the current DOM, so the callback fails.
  2. The action triggered before controller initialization cannot call back to the controller method This problem is because I declared an action in the code and also executed dispatch in the controller, but at this time, because the target controller has not yet been initialized, it seems that the code does not have any syntax or use any syntax. It looks like there is no syntax or usage error in the code, but it is impossible for the action to trigger the callback successfully.