DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Understanding importmap-rails

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

And app/javascript/turbo_stream_actions/set_data_attribute.js becomes:

import set_data_attribute from "turbo_stream_actions/set_data_attribute"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can create your custom action at app/javascript/turbo_stream_actions/set_data_attribute.js:

export default function() {
  // your custom logic
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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)