Skip to content Interested in a powerful Rails UI library?

Loading Stimulus controllers on demand in Rails with Vite

Published on
Loading Stimulus controllers on demand in Rails with Vite

If you’re working with a huge app that has lots of Stimulus controllers, the recommended strategy to load all controllers upfront might not be suitable for you.

A few months ago, I encountered this problem at work while revamping how we load stimulus controllers on our Rails app to improve performance and reduce bundle size.

Context

We are working with a Rails app. We have normal Stimulus controllers that live in app/javascript/controllers/$NAME$_controller.js.

But we also heavily use view_components. A lot of view components have their own stimulus controllers too that live alongside their ruby/template/css files in app/components/$COMPONENT_NAME$_controller.js

We use vite for bundling our JS, thanks to vite_ruby. But you can probably adapt what I did here for other bundlers like webpack/rollup/esbuild with ease.

With vite, you usually have various entrypoints that you can then include as needed based on the interface. For example, you might have a separate entrypoints for admin, your marketing site, and that of the public facing side of your app. Webpack(er) calls these “packs”.

Alternatives

Normally, a small sized app can usually just load all the controllers upfront in a single entrypoint and call it a day.

And that’s what the official docs recommend. You can justify that on a small-medium sized app as usually it’s not a lot of JS or all the JS is going to be needed soon anyway. And so the cost to load it may turn out to be a much better/simpler option.

Some bigger apps might also split loading stimulus controller into different entrypoints for different interfaces. One drawback to this splitting approach is that you have to maintain which controllers are to be loaded on a particular entrypoint in one way or another.

Or you might be fine with loading controllers lazily on the client as they become required using something like stimulus-controller-resolver.

For us, none of this was enough. The Rails app I was working with is huge. Really huge! It has a lot of different interfaces. And some of these interfaces by themselves are pretty big. And the approach to lazily load controllers can cause layout shifts in certain cases that we weren’t too happy with.


We needed a way to only load JS for stimulus controllers that were used when the page was rendered.

And this needed to happen during rendering on the server by inserting <script> tags in the <head> tag so browsers load these scripts ASAP.

NOT lazily through a client side helper that watched the DOM for any elements with a data-controller attribute.

But also a way to load additional controllers if something changed due to an AJAX update.

Coming soon: The only Rails UI library you'll ever need

  • Includes a lot of components necessary to build a modern app
  • Dark mode support out of the box · Simple to customize & extend
  • Simple primitives as well as complex components - not "another UI library"
  • Patterns I've found incredibly useful over the past years working with Rails
  • Subscribe now to receive updates and a free preview

The solution

What we settled on is configuring each stimulus controller as an entrypoint in vite. This is what our vite.json looks like roughly with those changes:

{
  "all": {
    "sourceCodeDir": "app/javascript",
    "additionalEntrypoints": [
      "app/components/*_controller.js",
      "app/components/**/*_controller.js",
      "app/javascript/controllers/*_controller.js",
      "app/javascript/controllers/**/*_controller.js",
      ...
    ],
    ....
  }
}

Then, to know which controllers were used during rendering a page, we had to introduce a requirement to never write data-controller manually.

Instead, we added a helper called stimulus_controller that accepts the controller name (identifier) and emits the data-controller attribute. But it also remembers the controller that was used in a Current attribute.

You can read more about current attributes here if you don’t know what they are.

### In app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :entrypoints_to_autoload
end
### In app/helpers/application_helper.rb

def stimulus_controller(identifier)
  # identifier is the name of the controller, e.g "clipboard", "admin--saved-replies", etc.
  entrypoint = js_entrypoint_for_controller(identifier)

  Current.entrypoints_to_autoload ||= Set.new
  Current.entrypoints_to_autoload << entrypoint

  # This isn't what our actual implementation looks like.
  # You might want to handle cases when a single element has multiple controllers.
  # And cases where you are using things like `content_tag` where you might want to return a hash instead of a string. We do all this and a lot more, but it's not important for the purposes of this article.
  "data-controller='#{identifier}'"
end


private

  IDENTIFIER_TO_ENTRYPOINT_CACHE = {}

  def js_entrypoint_for_controller(identifier)
    identifier = identifier.to_s

    unless IDENTIFIER_TO_ENTRYPOINT_CACHE.key?(identifier)
      # Convert the identifier to a path-like string
      # e.g `foo--bar--deep-dop` becomes `foo/bar/deep_dop`
      path = identifier.gsub('--', '/').tr('-', '_')

      if File.exist?(Rails.root.join('app/components', path + '_component_controller.js'))
        # It might be a view component stimulus js controller in app/components/
        path = '/app/components/' + path + '_component_controller.js'
      elsif File.exist?(Rails.root.join('app/javascript/controllers', path + '_controller.js'))
        # Or or a normal stimulus js controller in app/javascript/controllers
        path = 'controllers/' + path + '_controller.js'
      else
        raise "Can't find controller file for #{identifier}"
      end

      IDENTIFIER_TO_ENTRYPOINT_CACHE[identifier] = path
    end

    IDENTIFIER_TO_ENTRYPOINT_CACHE[identifier]
  end

<%# In any template like app/views/payments/show.html.erb %>
...
<div class='h-10 w-full' <%= stimulus_controller('user-info-card') %>>
...

That should give you all the entrypoints that a page rendered in Current.entrypoints_to_autoload. Now we need to render the script tag for those entrypoints.

<%# In your layout file like app/views/layouts/application.html.erb %>
<html>
  ...
  <head>
    ...
    <% if Current.entrypoints_to_autoload.present? %>
      <%= vite_javascript_tag(*Current.entrypoints_to_autoload, media: 'all')
      <% Current.entrypoints_to_autoload.clear %>
    <% end %>
    ...
  </head>
  <body>
    ...
    <%= yield %>
    ...
  </body>
</html>

And that should now be including scripts for the stimulus controllers when they are used on a page.

NOTE: If you’re using stimulus controllers in your layout file itself, then, those won’t be included in the head tag. For those, you will have to do the thing in the <head> again before closing the <body> tag.

But wait! That only loads the JS. We still need to register those controllers to our Stimulus application with the name of the identifier.

Let’s take a look at our stimulus application and an example stimulus controller so you know how to do that:

// In app/javascript/controllers/application.js
import { Application } from "stimulus";
export const application = Application.start(document.documentElement);

const registeredIdentifiers = {};

//-- A helper around application.register that only registers the controller if it hasn't been registered already --
export function registerController(identifier, ControllerConstructor) {
  if (registeredIdentifiers[identifier]) return;
  application.register(identifier, ControllerConstructor);
  registeredIdentifiers[identifier] = true;
}
// In app/javascript/controllers/utils_controller.js
import { Controller } from "stimulus";
import { registerController } from "../application";

export default class UtilsController extends Controller {
  doSomething(event) {
    console.log("doSomething");
  }
}
registerController("utils", UtilsController);

Optional - Automate registering controllers with a Vite plugin

One downside you can see from the above controller code is that we now have to remember to register the controller manually using application.register(identifier, klass).

That’s not a huge deal. But if you want you can totally automate that with a build plugin. The build plugin will allow you to write your stimulus controllers like:

// In app/javascript/controllers/utils_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  doSomething(event) {
    console.log("doSomething");
  }
}

Since we use vite, we can write a plugin for it that transforms this source to the one above automatically when serving/bundling the JS. It will transform your controller files to:

  1. Import a helper that registers the controller to your stimulus application from ‘app/javascript/controllers/application.js’
  2. Name your controller class if it’s unnamed like above
  3. Register your stimulus controller class with your application using the imported helper
// In vite.config.js
import { defineConfig } from 'vite'
import { registerStimulusControllersPlugin } from './app/javascript/controllers/loader/vite_plugin'
...

export default defineConfig({
  plugins: [
    registerStimulusControllersPlugin,
    ...
  ],
  ...
})

// In app/javascripts/controllers/vite_plugin.js
import {
  controllerClassNameFromControllerName,
  controllerNameFromFilePath,
} from "./helpers";

/*
 *  A vite plugin that transforms files that end with _controller.js to register them with Stimulus.
 */
export const registerStimulusControllersPlugin = {
  name: "Register stimulus controllers",
  transform(code, id) {
    // We assume all files that end with _controller.js are Stimulus controllers.
    if (!id.endsWith("_controller.js")) return;

    // And we assume all file should define a class then extends `Controller` or `extends SomeOtherController`..
    if (!code.match(/extends \S*?Controller/)) return;

    // We get the controller name from the file path.
    // (i.e `./controllers/booking/something_component_controller.js` => `booking--something`)
    const controllerName = controllerNameFromFilePath(id);

    // This should never happen but -\_(o_O)_/-
    if (!controllerName)
      throw new Error(`Could not register controller while transforming ${id}`);

    // We'll add an import for the registerController helper that registers a controller class for a given identifier
    const applicationImport = `import { registerController } from '~/controllers/loader/application'`;

    // This regex matches the class definition and captures the class name (if defined).
    // Some examples of what it considers a successful match:
    //   export default class extends Controller
    //   class extends Controller
    //   export default class BookingSomething extends Controller
    //   class BookingSomething extends Controller
    //   class BookingSomething extends BaseAnotherController
    //   export default class extends BaseAnotherController
    //   ...
    // Note: We don't always have a class name. We'll generate one later if we don't.
    const classDefRegex =
      /((?:export default )?class )(\S*\s+)?(extends \S*Controller)/;

    // Match the code via the regex above
    const matches = code.match(classDefRegex);

    // This means we are defining controllers in a way we don't support. Let's raise.
    if (!matches || !matches[1] || !matches[3]) {
      throw new Error(
        `Could not register controller while transforming ${id}. Could not find a valid controller class definition`
      );
    }

    // If we have a class name, we'll use it. Otherwise we'll generate one from the file path.
    const controllerClassName = matches[2]
      ? matches[2].trim()
      : "Controller_" + controllerClassNameFromControllerName(controllerName);

    // We replace the class definition so it is always named
    const codeWithNamedClass = code.replace(
      `${matches[1]}${matches[2] || ""}${matches[3]}`,
      `${matches[1]}${controllerClassName} ${matches[3]}`
    );

    // This is the statement that registers the controller with Stimulus.
    const registerController = `registerController('${controllerName}', ${controllerClassName})`;

    // We transform the code by
    // 1. Adding the import
    // 2. Then our transformed code that always has a class name in the definition
    // 3. And then adding the registration statement.
    return {
      code: `${applicationImport}\n${codeWithNamedClass}\n${registerController}`,
    };
  },
};
// In app/javascript/controllers/helpers.js

export const CONTROLLER_ATTRIBUTE = "data-controller"
const CONTROLLER_FILENAME_REGEX = /^(?:.*?(?:controllers|components)\/|\.?\.\/)?(.+?)(?:(?:[_-]component)?[_-]controller\..+?)$/

//-- Given a file path, returns the controller name (identifier) --
// Example: "./controllers/foo_bar_controller.js" => "foo-bar"
// Example: "./controllers/admin/dum/foo_bar_controller.js" => "admin--dum--foo-bar"
export function controllerNameFromFilePath(filePath: string) {
  const name = (filePath.match(CONTROLLER_FILENAME_REGEX) || [])[1]
  if (name) return name.replace(/_/g, "-").replace(/\//g, "--")
  return
}

//-- Given a controller name (identifier), returns a string that can be used as a clsas name for that controller --
// Example 'admin--dum--foo-bar-' -> 'admin__dum__foo-bar
export function controllerClassNameFromControllerName(controllerName) {
  return controllerName.replace(/-/g, "_")
}

Lazy loading controllers after initial page load

This part is only necessary if you do AJAX requests that modify the DOM elements on the page and introduce new elements that use stimulus controllers that were not there on the initial page load.

We need a way to load the javascript for these controllers dynamically as they are introduced on the page.

We can use the AttributeObserver provided by stimulus (which IIRC uses MutationObserver under the hood) to watch the DOM for any new stimulus controllers and load them as soon as they come into play.

// In your entrypoints like app/javascript/entrypoints/admin.js
import "../controllers/lazy_loader";
// In app/javascript/controllers/lazy_loader.js

import { AttributeObserver } from "@hotwired/stimulus"
import {
  CONTROLLER_ATTRIBUTE,
  extractControllerNamesFrom,
  modulesByControllerName,
} from "./helpers"

// All the available controllers in the world
export const AVAILABLE_CONTROLLERS = modulesByControllerName(
  import.meta.glob([
    "../**/*_controller.js",
    "../../../components/**/*_controller.js",
  ])
)

//-- Lazy load all controllers that are inside the element's descendant's data-controller attributes --
function lazyLoadControllersInside(element: Element) {
  return Array.from(element.querySelectorAll(`[${CONTROLLER_ATTRIBUTE}]`))
    .map(extractControllerNamesFrom)
    .flat()
    .forEach(controllerName => AVAILABLE_CONTROLLERS[controllerName]?.())
}

//-- Lazy load all controllers inside the element's data-controller attribute --
function lazyLoadControllersForElement(element: Element) {
  return extractControllerNamesFrom(element).forEach(controllerName =>
    AVAILABLE_CONTROLLERS[controllerName]?.()
  )
}

//-- Watches the given element (and it's descendants) for changes in it's child list or attributes, and lazy load new controller as soon as required --
function lazyLoadControllersOnChange(element: Element) {
  const observer = new AttributeObserver(element, CONTROLLER_ATTRIBUTE, {
    elementMatchedAttribute: lazyLoadControllersForElement,
    elementAttributeValueChanged: lazyLoadControllersForElement,
  })
  observer.start()
}

lazyLoadControllersInside(document.documentElement)
lazyLoadControllersOnChange(document.documentElement)
// In app/javascript/controllers/helpers.js
// Part of this file is also included above that contains things like CONTROLLER_ATTRIBUTE, controllerNamefromFilePath

//-- Given an element, returns array of controller names used by that element (i.e in the data-controller attribute) --
export function extractControllerNamesFrom(element) {
  return (
    element
      .getAttribute(CONTROLLER_ATTRIBUTE)
      ?.split(/\s+/)
      ?.filter(content => content.length) || []
  );
}

//-- Converts the hash/object returned by vite's import.meta.glob (which is a hash of filePath to modules/module importer) to a hash of controller name to modules --
export function modulesByControllerName(modules) {
  const result = {};
  Object.entries(modules).forEach(([filePath, mod]) => {
    const controllerName = controllerNameFromFilePath(filePath);
    if (controllerName && mod) result[controllerName] = mod;
    else console.error(`Could not find controller name for ${filePath}`);
  });
  return result;
}

Conclusion

And that’s all. You should now have a system for automatically loading controllers that were used on the page when rendering. And also the ability to automatically load new controllers when they are dynamically introduced on the page through an AJAX request.

It might seem a lot considering it’s just loading stimulus controllers, but depending on your situation, it might be very worth it. It definitely was for us.

We did consider a bunch of alternative approaches, but settled on this approach as we found this to be the best fit for our use-case.

The code here is inspired from the actual implementation I did on the Rails app I was working on when I came across this problem, but I’ve modified/removed a lot of things that weren’t directly related.

If you’ve got other ideas or questions, feel free to tweet at me @owaiswiz.

Get more articles like this

Subscribe to my newsletter to get my latest blog posts, and other freebies like Treact, as soon as they get published !