June 08, 2018

This week I installed StimulusJS into a Rails app that I've been developing. So far it seems like a good fit, helping me to organize and re-use Javascript functions without the hassle and overhead of a large framework. I'm a Rubyist at heart, so this minimal approach to Javascript fits me better than some of the other currently popular options. At least, that's my suspicion after using it for a few days.

If you haven't used StimulusJS, it revolves around controllers written in Javascript. These controllers access HTML elements specified by data attributes:

  • data-controller indicates which controller contains the relevant JS code,
  • data-target indicates that an HTML element is relevant to the JS code and gives it a name,
  • data-action indicates that a change to this element triggers a specific controller action.

Here's an example similar to one that I coded yesterday. Our business uses a variety of billing contract types, each with its own monthly cost. There is a ContractType model that contains the default_cost for Contracts of that type. In my form for making a new Contract, I wanted the monthly_cost of the Contract to change to the default_cost of the ContractType whenever a new ContractType is selected.

<!-- contracts/new.html.erb (not working) -->
<form data-controller="contracts">
  <select name="contract[contract_type]" id="contract_contract_type"
  data-target="contracts.contractType" data-action="contracts#updateCost">
    <!-- options for each contract type, containing name & id -->
  </select>
  <input type="number" name="contract[monthly_cost]" id="contract_monthly_cost"
  data-target="contracts.monthlyCost">
</form>

Here's the Stimulus contracts controller:

// contracts_controller.js (not working)
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "contractType", "monthlyCost" ]
  updateCost() {
    const contractTypeId = String(this.contractTypeTarget.value) // value from select box
    const monthlyCost = this.monthlyCostTarget // field to fill in
    const contractTypeCost = 'Uh-oh' // How do we get contractType's default cost?
    monthlyCost.value = Number(contractTypeCost).toFixed(2)
  }
}

A few things to notice:

  • The data-target elements from the HTML are listed in the controller's targets array, and each element of that array is available in the class by adding the Target suffix to the element's name. For example, data-target="contracts.contractType" is listed in the target array of the contracts controller as contractType, which is then accessed in the JS code as this.contractTypeTarget.
  • Stimulus watches for changes to DOM elements that have a data-action attribute, so when the select box value is changed, the contracts controller will execute the updateCost() function.
  • There is a problem. The default_cost of each ContractType is stored in the database and not immediately accessible to the client-side Javascript code. The select box only contains the name and ID of each option.

How could I fix this?

The first thing I tried to do was to pass parenthetical arguments via the data-action attribute. This did not work. Stimulus is not designed to be used in that way.

I considered setting up an API call to query the database, but that seemed like a lot of work. The primary purpose of frameworks is to make things simpler, and making additional HTTP requests to API endpoints that don't exist yet is not a simple solution.

Thankfully, there's a much easier way. I chose to add the relevant pricing information into the dataset of the select element.

First, I prepared the data in the Rails controller as a JSON object:

# contracts_controller.rb

# Create JSON object
# key = contract_type.id, value = contract_type.default_cost
@contract_types = ContractType.all.order :name
@contract_type_data = @contract_types.pluck(:id, :default_cost).to_h.to_json

... then I added it to the select box as a data-info attribute (there's nothing special about data-info; any unused data attribute would work):

<!-- contracts/new.html.erb -->
<form data-controller="contracts">
  <select name="contract[contract_type]" id="contract_contract_type"
  data-target="contracts.contractType" data-action="contracts#updateCost"
  data-info="<%= @contract_type_data %>">
    <!-- options for each contract type -->
  </select>
  <input type="number" name="contract[monthly_cost]" id="contract_monthly_cost"
  data-target="contracts.monthlyCost">
</form>

... and finally, here it is in the Stimulus controller:

// contracts_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "contractType", "monthlyCost" ]
  updateCost() {
    const contractTypeId = String(this.contractTypeTarget.value) // value from select box
    const contractTypesInfo = JSON.parse(this.contractTypeTarget.dataset.info) // data object
    const monthlyCost = this.monthlyCostTarget // field to fill in
    const contractTypeCost = contractTypesInfo[contractTypeId]
    monthlyCost.value = Number(contractTypeCost).toFixed(2)
  }
}

Now when a user selects a ContractType in the select box, the value of that box is used as a key to access a JSON object containing the correct default_cost.

Is there any problem with this solution? Not from my perspective, although a large enough data object would look pretty strange crammed into an HTML data-info attribute. If there was a huge data object or sensitive data involved, then an API call with a narrower query to the database would probably be better so as to be more specific about which data reaches the client. In this case however, I'm pleased with this solution, as well as with Stimulus in general.