Using Javascript libraries in a Hotwire app

I love Hotwire. More than anything else, it's got me excited and enjoying writing Rails apps, after a lull of around five years. I'm now able to write apps that feel increibly modern, with fast, responsive user-interfaces and web-socket based real-time updates with very little Javascript. And for the JS that I do need to write, Stimulus is (unsurprisingly) a perfect fit - Stimulus controllers connecting to elements and doing their thing automagically when Turbo injects them into the page.

But not everything fits neatly into the Hotwire framework. In particular, there's a world of Javascript libraries out there that it would be foolish to reimplement. However, as Turbo changes not only how pages load, but the lifecycle of elements, as Turbo removes and replaces them from the page, it can be tricky to see how to integrate those libraries into your apps.

After 18 months of working with Hotwire on Version 2 of Collabor8Online, this is how I do it.

Stimulus and Turbo are designed to work together. In particular, Stimulus understands the changes that Turbo will make to your pages. So, I use Stimulus to load up my Javascript libraries and ensure that everything works as expected.

Loading Bootstrap into a Hotwire page

Collabor8Online is built using a custom Bootstrap theme (yeah yeah, I know, Tailwind - but I was in a hurry and I know Bootstrap very well). For the most part, Bootstrap is just CSS, so adding the Bootstrap module to yarn (C8O/2 started off on Rails 6.1 and I've not had time to upgrade to Rails 7+ and start using import-maps yet) and importing it via your packs/application.js file is simple. But some Bootstrap components - the modal, popover and tooltip - have Javascript that needs to be initialised when the page loads.

So I have added a Stimulus bootstrap controller, which initialises the Bootstrap JS, and then attached that controller to the body of every page. Every time a turbo:load event fires on the main document (the Turbo equivalent of document.ready) , the Bootstrap controller's attach method is triggered.

<body data-controller="bootstrap" data-action="turbo:load@document->bootstrap#attach"> 
# app/views/layouts/application.html.erb
import { Controller } from "@hotwired/stimulus"
import { Modal, Popover, Tooltip } from "bootstrap"

export default class extends Controller {

  attach(event) {
    document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
      new Tooltip(el);
    })
    document.querySelectorAll('[data-bs-toggle="popover"]').forEach(el => {
      new Popover(el);
    })
    document.querySelectorAll('[data-toggle="modal"]').forEach(el => {
      el.addEventListener('click', evt => {
        evt.preventDefault();
        let target = document.getElementById(evt.target.dataset.target);
        let modal = new Modal(target);
        modal.show();
      })
    })
  }
}

# app/javascript/controllers/bootstrap_controller.js

Very simple and it does the job.

Using chart.js in a Hotwire app

We use the popular chart.js library for our dashboard graphs. On a C8O dashboard you can configure as many "tiles" as you want, and each tile fetches and displays different summary information from across the system. Because there could be lots of tiles and some tiles take a while to load we use a lazy-loaded turbo-frame to display the contents.

    <% project.tiles.each do |tile| %>
      <%= tag.div class: css_class_for_dashboard_tile(tile) do %>
        <%= turbo_frame_tag dom_id(tile), src: dashboard_tile_path(tile), loading: "lazy" do %>
          <%= spinner %>
        <% end %>
      <% end %>
    <% end %>
# fragment from app/views/project_dashboards/_dashboard.html.erb

The chart tiles look like this:

<%= turbo_frame_tag dom_id(dashboard_tile) do %>
  <%= card_panel cls: "shadow ragged" do %>
  ... various container and title elements ...
    <% chart_data = # code to load the chart data from the dashboard_tile %>
    <%= pie_chart labels: chart_data.names, data: chart_data, colours: chart_data.colours, dataset_title: t('dashboard_tiles.document_status') %>
  <% end %>
<% end %>
# adapted from app/views/dashboard_tiles/_document_status_tile.html.erb

And pie_chart is a helper method that creates a canvas in the format that chart.js expects:

  def pie_chart(id: "", cls: "", chart_title: "", colours: [], labels: [], data: [], dataset_title: "")
    tag.canvas id: id, class: cls, style: "width: 85%; height: 85%;", data: { controller: "pie-chart", pie_chart_labels_value: labels.join(","), pie_chart_data_value: data.join(","), pie_chart_colours_value: colours.join(","), pie_chart_chart_title_value: chart_title, pie_chart_dataset_title_value: dataset_title } do
      "..."
    end
  end
# fragment from app/helpers/graph_helper.rb

Note that the canvas includes data-controller="pie-chart" tag - this triggers our Stimulus controller.

The pie_chart_controller is a Stimulus controller with a load of values (Stimulus’ private variables that map to attributes on the attached element) representing the data that will be passed to chart.js. When the controller is connected - that is when the turbo-frame loads and the canvas tag is drawn onto the page, the controller then creates a configuration and then builds the chart itself.

import { Controller } from "@hotwired/stimulus"
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);

export default class extends Controller {
  static values = { labels: String, data: String, colours: String, datasetTitle: String, chartTitle: String }

  connect() {
    const labelsArray = this.labelsValue.split(',')
    const dataArray = this.dataValue.split(',').map(v => parseInt(v))
    const colourArray = this.coloursValue.split(',')
    const data = {
      labels: labelsArray,
      datasets: [{
        data: dataArray,
        backgroundColor: colourArray,
      }],
    }
    const config = {
      type: 'doughnut',
      data: data,
      options: {
        responsive: true,
        plugins: {
          legend: {
            position: 'top',
          },
          title: {
            display: false,
          }
        },
      }
    }
    const ctx = this.element.getContext('2d');
    new Chart(ctx, config);
  }
}
# app/javascript/controllers/pie_chart_controller.js 

Firstly, it takes the values that were passed in as data props on the canvas and converts them from strings into arrays. Then it builds a chart.js configuration and tells chart.js to use that configuration to draw the chart on this.element. Because the data-controller attribute is attached to the canvas tag, this.element is the canvas itself.

As the chart object is entirely internal to the Stimulus controller, when the canvas element is removed from the page, the controller will go out of scope and it, and any objects it holds, destroyed. If external cleanup were required, we could add a disconnect method to the controller that tidies up after itself.

So, by leveraging Stimulus' knowledge of Turbo, we can ensure that external Javascript libraries are loaded up and configured at just the right time

Rahoul Baruah

Rahoul Baruah

Rubyist since 1.8.6. I like hair, dogs and Kim/Charli/Poppy. Also CTO at Collabor8Online.
Leeds, England