Using ViewComponents with stimulus controllers and import maps

ViewComponents look great.

They replace Rails partials, which are slow and disconnected from the controller which spawns them. This means it's easy to have a partial that uses a variable internally, but if you accidentally remove it from the controller, or you use that partial elsewhere (and partials are designed for reuse) then the partial can fail to render. I've taken to adding a comment to the top of every partial, listing the expected inputs. ViewComponents declare their inputs and keep everything in one place.  And they make it easy to divide your pages into reusable chunks, making development simpler and more fun.

What is a ViewComponent?

ViewComponents are ruby classes that know how to render themselves in a ViewContext. They have a standard initialize constructor (oh, how many times have I created bugs because I used the English spelling initialise) and you pass your variables in there. They're also easily tested away from the rest of the Rails stack, which is fast and reduces dependency problems.

When generating the component you can opt to have it generate an associated template (HTML/ERB) file, a translation (YML) file and a stimulus controller, so everything related to the component is next to each other, rather than scattered all over the place, which is the norm in Rails.

But the documentation only explains how to add these Stimulus controllers in when you're using webpack. And for my latest project I thought I should use the all-new Rails Way and use Import Maps.

How do import maps work?

Firstly, a disclaimer. I have next to no understanding of how javascript packages work. I managed to get webpack (and Rails' webpacker gem) working; somehow it keeps on working and I don't really get why I haven't broken it yet. So the initial promise of import maps, doing away with all that added complexity, seemed like heaven to me.

And in theory, import maps are very simple. They are literally a map of your imports - you say import Whatever from "some-package", it looks up some-package in the map and converts that into the URL or path to the package in question. If you view your page source, you can see the import map is added into the HEAD of your document (which was vital for me as I was trying to figure out how to get this to work).

And it is really that simple for external packages.

Want to include React? Type bin/importmap pin react and Rails will add pin "react", to: "https://ga.jspm.io/npm:react@18.2.0/index.js" to your importmap.rb. Rails then generates the import map script block in your document, so you can import * as React from "react" in your javascript files and they will know where to look.

It also works pretty well for standard Rails bundling.

I'm still using sprockets as my asset pipeline (switching to CSS and JS bundler was too much to do in one step when I'm still not sure what how it all works) - and in there, you have a app/assets/config/manifest.js file that tells sprockets which assets you are using. It loads up your app/assets folder (for stylesheets, images and so on) and your app/javascript folder (which includes your stimulus controllers) and bundles them up. It does this dynamically in development, whereas in production, calling bin/rails assets:precompile dumps all your files, fingerprinted for cache-responsiveness, into public/assets so your web-server can hand them out (and safely cache them) to whoever wants them.

Then, when your front-end app requests one of these files, it can look it up in the import map. In development it will load it from the file-system, whereas in production your web-server will publish it from https://myapp.com/assets/

A quick note about folders. Sprockets wants to take your CSS files and your JS files and bundle them up into public/assets/application.css or public/assets/application.js (which is what those link_tree and require_tree declarations are for). But it also knows that if you're referencing files outside of that standard tree structure, it needs to keep the folders intact. So it will put things into public/assets/some_folder/my_file-some-cache-digest-thing.js or whatever. And that becomes important in a minute.

Getting ViewComponents, stimulus and import maps to work together

Using ViewComponents means you are stepping outside of the standard app/assets and app/javascript structure - and unfortunately, the way Import Maps and Sprockets work collide.

First of all, you need to tell Sprockets where to find your controller files. In manifest.js add //= link_tree ../../components .js to tell it to look in your app/components folder for JS files. And add an initialiser (I call it config/initialisers/view_components.rb - which I've included below) that tells Rails to add app/components to your assets path.

Then you need to include these files in your import map. At first I thought I could just use pin_all_from "app/components", under: "components" - which mirrors the declaration that stimulus uses itself. What this declaration does is load up all the javascript files under app/components, figures out their cache-fingerprinted filenames and then loads them all into a pseudo-package called components. Stimulus, in its index.js file then calls eagerLoadComponentsFrom("components", application) - referencing the pseudo-package and registers all your Stimulus controllers.

But it doesn't work.

Because sprockets has helpfully loaded all these javascript files into the appropriate folders within public/assets, stimulus happily registers them and then, when they're needed, Stimulus cannot find them. Say you have app/components/hello_controller.js; Stimulus registers /hello_controller.js as HelloController, but the import map has made it available at /components/hello_controller.js so when you try to load your page, the file is not found.

I'm still trying to figure out a way round this. And it's not helped by the fact that Rails and the browser conspire to cache files in a somewhat unpredictable way. The other week I got it all working without realising it was loading files from cache. As soon as I rebuilt my docker image, it all fell to pieces and I had to scrap the first draft of this article.

However, now I think I've got it - but you need to namespace your controllers. This isn't a problem for me - my generic components are in the format Ui::ThisComponent and my specific components are ClientName::ThatComponent or ResourceName::TheOtherComponent.

Once namespaces are involved, the conflict between the import map and sprockets can be resolved.

When namespaced, Sprockets will generate a javascript file at public/assets/namespace/whatever.js - so Ui::HelloController will be generated into public/assets/ui/hello_controller-cache-digest-stuff.js.

We have to tell the import map to pin all our files, repeating the declaration for each namespace - pin_all_from "app/components/namespace", under: "controllers/namespace" Using our Ui::HelloController example, we would pin_all_from "app/components/ui", under: "controllers/ui". Also note that we're no longer using the components package - instead we're saying to include the files as a sub-folder of the controllers package. The generated import map will then include a reference to ui/hello_controller-cache-digest-stuff.js within the controllers pseudo-package.

Stimulus uses index.js to register every controller it finds in the controllers package - using the eagerLoadFrom("controllers", application) statement. And our import map now says that our components are within that package. Stimulus already knows about the controllers package, as it uses that to load the standard app/javascript/controllers files - so it strips the preceding controllers from the path name and looks for our file in /ui/hello_controller-cache-digest-stuff.js. Which sprockets has generated for us so our web-server will happily hand it out on demand.

Finally, one last bit of ViewComponent configuration. I create an initialiser - config/initialisers/view_component.rb that sets things up for me. It looks like this:

Rails.application.configure do
  config.assets.paths << Rails.root.join("app/components")
  config.importmap.cache_sweepers << Rails.root.join("app/components")

  config.view_component.view_component_path = "app/components"
  config.view_component.component_parent_class = "Ui::BaseComponent"
  config.view_component.generate.sidecar = false
  config.view_component.generate.stimulus_controller = true
  config.view_component.generate.locale = true
  config.view_component.generate.preview = false
end

We tell the asset pipeline to include our components folder, we tell the cache sweeper to include it as well (so, in development, when we hit reload in our browser, our javascript should get reloaded instead of cached). And finally I tell ViewComponent that I want my parent class to be Ui::BaseComponent, that I want a stimulus controller and translation file to be generated by default, but I don't want a preview component (similar to Rails' in-built email previews) and I don't want a "sidecar" directory (putting these ancillary files into a separate folder).

Tl;dr

To allow ViewComponents to have their own, local, Stimulus controllers and have them load automatically when your Rails app is using import maps, you need to do the following:

  1. Namespace your components - Sprockets and Import Maps work against each other, generating slightly different paths for your files, meaning that without namespaces, your javascript won't get loaded at run-time.
  2. Tell the Asset Pipeline about the location of your controllers - add a link_tree entry to app/assets/config/manifest.js and an initialiser that adds your component path to Rails' assets.paths setting. This makes sure your javascript files get bundled, minified and deployed in production.
  3. Tell the Import Map to include all javascript files from your components by using pin_all_from, adding them as sub-folders in the controllers pseudo-package. This tells any other javascript that wants to use your files where to find them.
  4. Tell Stimulus to load and register your controllers using eagerLoadControllersFrom("controllers", application). This will use the import map to locate any files in the controllers pseudo-package and register them so they run when Stimulus sees a data-controller attribute.
  5. Build something amazing, then have a cup of tea and a lie down.
Rahoul Baruah

Rahoul Baruah

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