This article was originally published on Rails Designer
If you've worked with modern JavaScript, you're familiar with ES modules and import statements. Rails apps can use esbuild (or vite or bun) for this, but the default option (Rails way) is importmap-rails. It lets you write import { Controller } from "@hotwired/stimulus" without any build step at all.
Ever thought about how this works?
Import maps, just a web standard
Import maps are a web standard that tells browsers how to resolve bare module specifiers. A bare module specifier looks like import React from "react", which isn't valid ESM on its own. The browser needs an absolute path (/assets/react.js), relative path (./react.js), or HTTP URL (https://cdn.example.com/react.js).
Import maps provide the translation:
<script type="importmap">
{
"imports": {
"application": "/assets/application-abc123.js",
"@hotwired/stimulus": "/assets/stimulus.min-def456.js",
"controllers/application": "/assets/controllers/application-ghi789.js"
}
}
</script>
When your JavaScript says import { Controller } from "@hotwired/stimulus", the browser looks up "@hotwired/stimulus" in this map and loads /assets/stimulus.min-def456.js.
The importmap-rails gem generates this script tag for you. It appears in your layout via <%= javascript_importmap_tags %>, which reads your config/importmap.rb configuration and outputs the importmap along with modulepreload links (which tell the browser to start downloading your JavaScript files immediately, rather than waiting to discover each import one at a time) and your application entry point.
Configuring with pin
In config/importmap.rb, you define what goes in that map:
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@rails/request.js", to: "@rails--request.js.js"
Each pin creates a mapping (entry) in the importmap. The first argument is the bare module specifier you'll write in your import statement. The to: attribute specifies which file should be loaded from your asset pipeline (typically from app/javascript or vendor/javascript).
To add a package from npm, run:
./bin/importmap pin package-name
This downloads the package file into vendor/javascript and adds the pin to your config/importmap.rb. The default is to use JSPM.org as the CDN, but you can specify others:
./bin/importmap pin react --from unpkg
./bin/importmap pin react --from jsdelivr
The downloaded files are checked into your source control and served through your application's asset pipeline.
Mapping directories with pin_all_from
Instead of pinning files individually, you can map entire directories:
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
The under: attribute creates a namespace prefix. Every file in the directory becomes “importable” with that prefix.
So app/javascript/controllers/reposition_controller.js becomes:
import RepositionController from "controllers/reposition_controller"
And app/javascript/turbo_stream_actions/set_data_attribute.js becomes:
import set_data_attribute from "turbo_stream_actions/set_data_attribute"
Example: custom Turbo Stream actions
Let's now look at how all these pieces connect. Say you want to register a custom Turbo Stream action (as I wrote about here and here).
First, in config/importmap.rb, you map the directory:
pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
When Rails generates the importmap (via <%%= javascript_importmap_tags %>), it scans that directory and creates entries for each file:
{
"imports": {
"turbo_stream_actions": "/assets/turbo_stream_actions/index-abc.js",
"turbo_stream_actions/set_data_attribute": "/assets/turbo_stream_actions/set_data_attribute-xyz.js"
}
}
Now you can create your custom action at app/javascript/turbo_stream_actions/set_data_attribute.js:
export default function() {
// your custom logic
}
And register it in app/javascript/turbo_stream_actions/index.js:
import set_data_attribute from "turbo_stream_actions/set_data_attribute"
Turbo.StreamActions.set_data_attribute = set_data_attribute
The browser sees "turbo_stream_actions/set_data_attribute", looks it up in the importmap, finds /assets/turbo_stream_actions/set_data_attribute-xyz.js, and loads it.
Finally, in your main app/javascript/application.js:
import "turbo_stream_actions"
Your custom action is now registered. The import path ("turbo_stream_actions/set_data_attribute") matches the under: namespace from your pin_all_from configuration.
How Stimulus controllers use this
The same pattern is used for Stimulus. In config/importmap.rb:
pin_all_from "app/javascript/controllers", under: "controllers"
The under: value here could be anything: "my_controllers" or "stimulus_controllers". But "controllers" is the convention. It becomes the import prefix for everything in that directory.
In app/javascript/controllers/index.js:
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
The application import comes from app/javascript/controllers/application.js:
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
The eagerLoadControllersFrom function scans the importmap for entries starting with "controllers/" and automatically imports and registers them. Add a new controller file and it's instantly available.
Because of pin_all_from with under: "controllers", the file app/javascript/controllers/application.js is “importable” as "controllers/application". The pattern is the same: directory structure maps to import paths through the under: namespace.
Top comments (0)