ViewComponent is a Rails library for building reusable front-end components.
I prefer organizing a component's files like this:
app/
├─ components/
├─ table/
├─ table_component.rb
├─ table_component.html.erb
├─ table_component.css
├─ table_component_controller.js
Traditionally, ViewComponents do not include CSS or JS files. However, I prefer co-locating them. This makes the component easy to reason about.
This requires a few custom changes, as ViewComponent does not provide an out-of-the box way to serve CSS or JavaScript files.
For my CSS setup, see this gist.
ViewComponent already supports the generation of Stimulus controllers:
rails g component Example --stimulus
But there's no built in way to load component controllers into the page.
- Add all components to the asset pipeline.
- Pin all component controllers to the Import Map
- Import and register nested controllers in
index.js
This makes Sprockets pick up the controller files. If you're using Propshaft, there's probably an equivalent syntax that does the same thing.
# config/application.rb
class Application < Rails::Application
config.assets.paths.push(*Rails.root.glob("app/components/**/")) # See config/importmap.rb
end
# config/importmap.rb
app_js = Rails.root.join("app/components")
app_js.glob("**/*.js").each do |path|
name = path.relative_path_from(app_js).to_s
pin "controllers/#{name}", to: name
end
Instead of calling the Stimulus standard loader eagerLoadControllersFrom
, I wrote a custom loader eagerLoadNestedControllersFrom
.
I adapted it from the Stimulus source code.
The main benefit is that it rewrites the controller names nicely.
For example, a controller app/components/table_component/table_component_controller.js
would ordinarily be called table/table-component
by Stimulus. So you'd use it like this:
<!-- app/components/table_component/table_component.html.erb -->
<div data-controller='table/table-component' data-action='click->table/table-component#clicked'>
</div>
Such repetition! Yuck! 🤮
My custom loader neatly rewrites the controller name to simply table
:
<div data-controller='table' data-action='click->table#clicked'>
</div>
Ahh! Much better 😌
// javascript/eager_load_nested_controllers.js
/*
Adopted from the Stimulus source at
https://github.com/hotwired/stimulus-rails/blob/3e168f07ba73248740f9dee2a49463893fd5be57/app/assets/javascripts/stimulus-loading.js
*/
const controllerAttribute = "data-controller";
const registeredControllers = {};
export default function eagerLoadNestedControllersFrom(under, application) {
const paths = Object.keys(parseImportmapJson()).filter((path) => {
const isMatch = path.match(new RegExp(`^controllers/.*_controller(.js)*$`));
return isMatch;
});
paths.forEach((path) => registerControllerFromPath(path, under, application));
}
const REGEXP = {
CONTROLLER_SUFFIX: "_controller",
COMPONENT_SUFFIX: "_component",
ALL_SLASHES: /\//g,
JS_EXTENSION: /.js$/,
DIRECTORY_PREFIX: /.*--/g,
ALL_UNDERSCORES: /_/g,
};
function registerControllerFromPath(path, under, application) {
const name = path
.replace(new RegExp(`^${under}/`), "")
.replace(REGEXP.CONTROLLER_SUFFIX, "")
.replace(REGEXP.COMPONENT_SUFFIX, "")
.replace(REGEXP.ALL_SLASHES, "--")
.replace(REGEXP.JS_EXTENSION, "")
.replace(REGEXP.DIRECTORY_PREFIX, "")
.replace(REGEXP.ALL_UNDERSCORES, "-");
// console.log(`Rewrote "${path}" -> "${name}"`);
if (!(name in registeredControllers)) {
import(path)
.then((module) => {
// console.log(`Registering ${name}`);
registerController(name, module, application);
})
.catch((error) =>
console.error(`Failed to register controller: ${name} (${path})`, error)
);
}
}
function parseImportmapJson() {
return JSON.parse(document.querySelector("script[type=importmap]").text)
.imports;
}
function registerController(name, module, application) {
if (!(name in registeredControllers)) {
application.register(name, module.default);
registeredControllers[name] = true;
}
}
Use the loader to register all controllers, including component controllers:
// app/javascript/controllers.index.js
import eagerLoadNestedControllersFrom from "eager_load_nested_controllers";
eagerLoadNestedControllersFrom("controllers", application);
Any Stimulus controller added to app/components/**/*_controller.js
will now be seamlessly loaded into the application!
In environments/development.rb
I prefer these settings:
# ViewComponent options
config.view_component.generate.stimulus_controller = true
config.view_component.component_parent_class = "BaseComponent"
# ViewComponent instrumentation for performance analysis
config.view_component.instrumentation_enabled = true
config.view_component.use_deprecated_instrumentation_name = false
# Show ViewComponents in Rack::MiniProfiler
Rack::MiniProfilerRails.subscribe("render.view_component") do |_name, start, finish, _id, payload|
Rack::MiniProfilerRails.render_notification_handler(
Rack::MiniProfilerRails.shorten_identifier(payload[:identifier]),
finish,
start
)
end