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?
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:firstname.lastname@example.org/index.js" to your importmap.rb. Rails then generates the import map script block in your document, so you can
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
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
A quick note about folders. Sprockets wants to take your CSS files and your JS files and bundle them up into
public/assets/application.js (which is what those
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
First of all, you need to tell Sprockets where to find your controller files. In
//= 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
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.
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
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
Once namespaces are involved, the conflict between the import map and sprockets can be resolved.
public/assets/namespace/whatever.js - so
Ui::HelloController will be generated into
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
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
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:
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
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).
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:
- Tell the Asset Pipeline about the location of your controllers - add a
app/assets/config/manifest.jsand an initialiser that adds your component path to Rails'
pin_all_from, adding them as sub-folders in the
- Tell Stimulus to load and register your controllers using
eagerLoadControllersFrom("controllers", application). This will use the import map to locate any files in the
controllerspseudo-package and register them so they run when Stimulus sees a
- Build something amazing, then have a cup of tea and a lie down.