The art of Turbo Mount: Hotwire meets modern JS frameworks

Cover for The art of Turbo Mount: Hotwire meets modern JS frameworks

Topics

DHH once likened Rails developers to Renaissance artists: full-stack virtuosos, like Leonardo da Vinci, who dabbled in engineering, painting, and everything under the sun. Emboldened by such high praise, I wanted to create something as beautiful, simple, and timeless as the smile of the Mona Lisa. Thus, recognizing that the current frontend development process has become overly complex, I decided to break the cycle of complexity and build an app that combines the best of both worlds: the simplicity of Hotwire and the interactivity of modern JavaScript frameworks.

In the absence of any wealthy Florentine merchant patrons to support this work, I’ve devised a straightforward example to illustrate this school of thought: we will take a simple CRUD demo application and add an interactive component to it, then we will replace this manual approach with the new Turbo Mount library, we’ll see how to work with it using prebuilt components, and finally, we’ll mount our own custom component.

Schedule call

Irina Nazarova CEO at Evil Martians

Schedule call

Excel-lent Palettes

Behold Excel-lent Palettes! An application which presents a veritable Sistine Chapel of color palettes, designed for use with Excel. It allows users to choose from a collection of breathtakingly divine color combinations, perfect for those who want their spreadsheets to be remembered for all the wrong reasons.

Color schemes from your nightmares

The simple addition of Turbo makes our app dynamic and responsive, and this is most good.

The design of this application is truly magnificent. But wouldn’t it be better to have an interactive demo component right in the app?

Yes, the critic is right… a fair, but just, assessment. There’s still a hitch in our grand plan: in order to effectively demonstrate these palettes, we want to introduce a fully functional Excel component, but sadly, the Hotwire ecosystem doesn’t offer this out of the box.

The challenge: highly interactive components

We have two options. One: build a fully-working excel-component ourselves. Two: use something like @fortune-sheet/react, a React component, which perfect for our needs. Given the tight time constraint—I choose the pre-existing solution. So, now we need to integrate this React component into our Rails app.

The standard way to initialize a React component is by adding initialization logic to the DOMContentLoaded callback:

import { createElement } from "react";
import { createRoot } from "react-dom/client";

import { Workbook } from "@fortune-sheet/react";

document.addEventListener("DOMContentLoaded", () => {
  const root = createRoot(document.getElementById("root"));
  root.render(createElement(Workbook, props));
});

Unfortunately, since we’re using Hotwire—Turbo Streams, Turbo Frames, and even simple navigation—all of them fire custom Turbo events we need to hook into. It’s possible to list all the events and add listeners to them, but this sort of code would be hard to maintain and prone to errors.

Thankfully, Stimulus plays nice with Turbo, so we can just use a Stimulus controller and hook into its lifecycle:

import { Controller } from "@hotwired/stimulus";
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { Workbook } from "@fortune-sheet/react";

export default class extends Controller {
  static values = {
    props: Object,
  };

  connect() {
    this.root = createRoot(this.element);
    this.root.render(createElement(Workbook, this.propsValue));
  }

  disconnect() {
    this.root.unmount();
  }
}

This setup initializes the React component when the controller connects, then unmounts it upon disconnection. Prop management is done using the Stimulus values. We can now use the resulting controller in the Rails view:

<div data-controller="workbook"
     data-workbook-props-value="<%= props.to_json %>"
></div>

Our grand vision is being manifest, and this method allows our app to be just as dynamic and responsive as our masterwork demands.

Masterwork? I am not impressed. What about managing dependencies?

Yes, one thing was left outside—managing dependencies.

Importmaps and interactive components

Since we are using the default Rails 7 way, we’re managing JavaScript dependencies with importmaps. In theory, adding the @fortune-sheet/react package is as simple as running:

bin/importmap pin react react-dom react-dom/client @fortune-sheet/react

But importmaps have limitations with dependency resolution, and in this case, it requires extra configuration:

# config/initializers/importmap.rb

# Using Skypack to fix resolution errors by resolving dependencies via CDN

# pin "immer" # @9.0.21
# fixes process is not defined error:
pin "immer", to: "https://cdn.skypack.dev/immer@9.0.21"

# pin "lodash" # @4.17.21
# fixes isPlainObject import error:
pin "lodash", to: "https://cdn.skypack.dev/lodash@4.17.21"

Importmaps provide simplicity, allowing us to manage JavaScript dependencies directly in a Rails application with no need for a separate build step. This integration is useful for smaller projects.

That said, importmaps often struggle with more complex dependencies, necessitating manual tweaks and complicating maintenance as the application grows. Further, we can only work with pre-built components, so, inside of Rails apps, frameworks like Svelte or any custom React/Vue components are unsupported.

For a more hardy solution, consider using Vite: it handles complex dependencies more efficiently, offers fast builds, and provides greater flexibility with modern JavaScript tooling. All of this makes it a better choice for larger or more complex projects.

Let’s continue using importmaps, and we’ll migrate to Vite later as the project grows. Here is the commit with the full implementation of the interactive component.

Excel component in action

Excel component in action

This looks gorgeous, and I’m sure we’ll add more interactive components in the nearest feature to further enhance the app. This means we’ll have to add more boilerplate code. Seems like the perfect opportunity to introduce Turbo Mount!

Reducing boilerplate with Turbo Mount

Turbo Mount represents the “golden ratio” of Hotwire and modern JavaScript frameworks. It allows us to seamlessly integrate interactive components, and boasts simple (yet powerful) features that bring balance to the force (wrong reference, but the art well is running dry):

  • Simplicity: Turbo Mount simplifies the process of mounting interactive components in Hotwire applications, and reduces the amount of boilerplate code required.
  • Flexibility: Turbo Mount supports custom controllers, allowing for the easy extension of the functionality of pre-built components.
  • Plays nice with Vite: Turbo Mount can automatically register components using Vite, eliminating the need for manual component addition to the initialization script.
  • Framework-agnostic: Turbo Mount supports React, Vue, and Svelte, making it an easy way to integrate components from different frameworks into your Hotwire application.

Without further ado, let’s install Turbo Mount:

$ bundle add turbo-mount

$ bin/rails generate turbo_mount:install

Installing Turbo Mount
Creating Turbo Mount initializer
What framework do you want to use with Turbo Mount? [react, vue, svelte] (react)
      create  app/javascript/turbo-mount-initializer.js
      append  app/javascript/application.js
Pinning Turbo Mount to the importmap
      append  config/importmap.rb
      append  config/importmap.rb
      append  config/importmap.rb
Pinning framework dependencies to the importmap
         run  bin/importmap pin react react-dom react-dom/client from "."
Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:react@18.3.1/index.js
Pinning "react-dom" to vendor/javascript/react-dom.js via download from https://ga.jspm.io/npm:react-dom@18.3.1/index.js
Pinning "react-dom/client" to vendor/javascript/react-dom/client.js via download from https://ga.jspm.io/npm:react-dom@18.3.1/client.js
Pinning "scheduler" to vendor/javascript/scheduler.js via download from https://ga.jspm.io/npm:scheduler@0.23.2/index.js
Turbo Mount successfully installed

This command will add all of the necessary dependencies and generate app/javascript/turbo-mount-initializer.js, the file where we’ll register our components with Turbo Mount:

// app/javascript/turbo-mount-initializer.js
import { TurboMount } from "turbo-mount";
import { registerComponent } from "turbo-mount/react";
import { Workbook } from "@fortune-sheet/react";

const turboMount = new TurboMount();
registerComponent(turboMount, "Workbook", Workbook);

That’s it! We can now remove the previously created Stimulus controller and the React component initialization code from our Rails view. Instead, we can use the turbo_mount helper to mount the component:

<%= turbo_mount("Workbook", props: {data:}) %>

This way we can easily mount our React component without writing any additional JavaScript code (see the corresponding commit).

Turbo Mount takes care of everything for us, allowing us to focus on building the interactive components that enhance our app.

Customizing Turbo Mount

Our next goal is to add a color picker that works smoothly with Rails forms. The main challenge is to sync the color selected by the user with a React component that we can’t modify directly without the build process. So, let’s walk through integrating a color picker into our app (without introducing Vite or other build tools).

First, we’ll integrate the color picker into our app using the turbo_mount helper. This setup includes Stimulus targets for both the color picker and the form field, allowing our Stimulus controller to interact with them:

<%= turbo_mount("HexColorPicker", props: { color: palette.colors[i] }) do |controller_name| %>
  <div data-<%= controller_name %>-target="mount"></div>
  <%= form.hidden_field :colors, multiple: true, value: palette.colors[i], data: { "#{controller_name}-target": "input" } %>
<% end %>

Next, we’ll create a Stimulus controller that updates the form field whenever a new color is selected by the user. This controller extends the Turbo Mount base controller, making integration with the React component easier:

// app/javascript/controllers/hex_color_picker_controller.js
import { TurboMountController } from "turbo-mount";

export default class extends TurboMountController {
  static targets = ["input", "mount"];

  get componentProps() {
    return {
      ...this.propsValue,
      onChange: this.onChange,
    };
  }

  onChange = (color) => {
    this.inputTarget.value = color;

    // Optionally, sync the data props if you need to re-render the component
    // using HTML attributes. The `setComponentProps` method is equivalent to
    // `this.propsValue = { ...this.propsValue, colors };`
    // but skips re-rendering the component:
    this.setComponentProps({ ...this.propsValue, color });
  };
}

Finally, we need to register our custom controller and the React component in the Turbo Mount initialization script:

import { TurboMount } from "turbo-mount";
import { registerComponent } from "turbo-mount/react";
import { HexColorPicker } from "react-colorful";
import HexColorPickerController from "controllers/hex_color_picker_controller";

const turboMount = new TurboMount();
registerComponent(
  turboMount,
  "HexColorPicker",
  HexColorPicker,
  HexColorPickerController,
);

With this approach, we can extend the functionality of prebuild components, sure, but let’s be honest, that’s going to be a lot of wiring in the commit. Anyway, we’re already pushing the limits of the build-less frontend. It’s time to introduce custom React components!

Color picker in action

Not bad, but we can make it better with custom components

Custom components and Turbo Mount

When it comes to integrating custom components, Turbo Mount is truly a blessing. To build a custom component, we need to introduce a build tool, and, as earlier was foretold, we made the simple move to Vite in this commit.

With Vite and ESBuild, we can use another feature of Turbo Mount: a registerComponents helper that automates loading components and custom controllers from a directory:

// app/javascript/turbo-mount-initializer.js
import plugin, { TurboMount } from "turbo-mount/react";
import { registerComponents } from "turbo-mount/registerComponents/vite";

const controllers = import.meta.glob("./**/*_controller.js", { eager: true });
const components = import.meta.glob("/components/**/*.jsx", { eager: true });

const turboMount = new TurboMount();
registerComponents({ plugin, turboMount, components, controllers });

Now, every time we add a new component or controller to the corresponding directory, Turbo Mount will automatically register it with the initializer–no more manual wiring needed.

We now have the perfect opportunity to customize the ColorPicker component by hiding the picker when the user isn’t interacting with it. Let’s create a custom component that adds this functionality:

// app/javascript/components/ColorPicker.jsx

import { useState, useRef } from "react";
import { HexColorPicker } from "react-colorful";

import { useOutsideClickHandler } from "~/hooks/useOutsideClickHandler";
import ColorList from "~/components/ColorList";

const ColorPicker = ({ colors, onChange }) => {
  const [colorList, setColorList] = useState(colors);
  const [editingIndex, setEditingIndex] = useState(null);
  const pickerRef = useRef();
  const colorListRef = useRef();

  useOutsideClickHandler([pickerRef, colorListRef], () =>
    setEditingIndex(null),
  );

  const handleColorChange = (color) => {
    const newColorList = [...colorList];
    newColorList[editingIndex] = color.toUpperCase();
    setColorList(newColorList);
    onChange(newColorList);
  };

  return (
    <div className="flex flex-wrap gap-4 w-full">
      <ColorList
        colorListRef={colorListRef}
        colorList={colorList}
        onColorRowClick={setEditingIndex}
      />

      {editingIndex !== null && (
        <div ref={pickerRef}>
          <HexColorPicker
            color={colorList[editingIndex]}
            onChange={handleColorChange}
          />
        </div>
      )}
    </div>
  );
};

export default ColorPicker;

Next, to match the new component with the custom controller, we need to rename it as turbo_mount_color_picker_controller.js. With that, we’re finally ready to replace the HexColorPicker components with the new custom ColorPicker:

<%= turbo_mount("ColorPicker", props: { colors: palette.colors }) do |controller_name| %>
  <div data-<%= controller_name %>-target="mount"></div>
  <%= form.hidden_field :colors, data: { "#{controller_name}-target": "input" } %>
<% end %>

Notice that we’re passing an array of colors, so we need to update the backend to accept that as well. Here’s the commit with the full implementation of the custom color picker. And that’s essentially it—no more manual registration, just simple and clean code, brought to you by the principle of convention over configuration.

Custom color picker in action

Now our color picker matches the beauty of the app.

Signing the canvas

Turbo Mount is more than just a library; it is a bold, artistic statement. A seamless blend of tradition and the avant-garde, an elegant bridge between the timeless elegance of Ruby syntax and the pulsating energy of modern web dynamics… an exquisite dance between the simplicity of Hotwire and the effervescent interactivity of contemporary JavaScript frameworks.

Okay, maybe it’s not THAT great, but it certainly empowers developers to craft truly excellent applications with ease!

If you’re interested in reducing complexity in your web projects, consider giving Turbo Mount a try. Explore the README and the source code on GitHub, and see how it might fit into your development workflow. Whether you’re looking to contribute or simply use it, your feedback and experiences are welcome!

Changelog

1.1.1 (2024-06-29)

  • Added the setComponentProps method to the Stimulus controller example.

1.1.0 (2024-06-17)

  • Added a note on ESBuild support.
  • Auto-loading syntax upgraded to one preferred by Turbo Mount 0.3.1.
Schedule call

Irina Nazarova CEO at Evil Martians

Playbook.com, Stackblitz.com, Fountain.com, Monograph.com–we joined the best-in-class startup teams running on Rails to speed up, scale up and win! Solving performance problems, shipping fast while ensuring maintainability of the application and helping with team upskill. We can confidently call ourselves the most skilled team in the world in working with startups on Rails. Curious? Let's talk!