Make sure you have setup Rails. You can read the guide here:
https://guides.rubyonrails.org/getting_started.html
Before we being make sure we are running node version 18 or above:
nvm use 18
Create our new app rails-view-components
rails new rails-view-components --css=tailwind --javascript=esbuild
cd rails-view-components
Spin up the server to test everything is running:
./bin/dev
https://evilmartians.com/chronicles/viewcomponent-in-the-wild-supercharging-your-components
Next we will install the super handy package from Evil Martians that make working with components even better. It will install both the gems we need to continue:
rails app:template LOCATION="https://railsbytes.com/script/zJosO5"
Step through the installer. I like to keep components in the views directory.
Where do you want to store your view components? (default: app/frontend/components) app/views/components
Would you like to use dry-initializer in your component classes? (y/n) y
Do you use Stimulus? (y/n) y
Do you use TailwindCSS? (y/n) y
Would you like to create a custom generator for your setup? (y/n) y
Which template processor do you use? (1) ERB, (2) Haml, (3) Slim, (0) Other 1
Most of the above is pretty straight forward except for the dry-initializer gem:
https://dry-rb.org/gems/dry-initializer/3.1/
To keep things tidy we will add controller views in to their own directory.
views/
components/
layouts/
controllers/
my_controller/
index.html.erb
mailers/
my_mailer/
message.html.erb
Add the following to app/controllers/application_controller.rb
append_view_path Rails.root.join("app", "views", "controllers")
Do the same for app/mailers/application_mailer.rb
append_view_path Rails.root.join("app", "views", "mailers")
I like to skip the tests and preview file until I need them. We can do this by modifying the generator lib/generators/view_component/view_component_generator.rb
:
class_option :skip_test, type: :boolean, default: true
class_option :skip_system_test, type: :boolean, default: true
class_option :skip_preview, type: :boolean, default: true
I also like to remove the with_collection_parameter
from the template as I dont use it. Remove it from the file lib/generators/view_component/templates/component.rb.tt
:
with_collection_parameter :<%= singular_name %>
Let's create a basic button component to test this out:
rails g view_component Button
We need a place to render our cool new button. Let's setup a controller in app/controllers
called marketing_controller.rb
class MarketingController < ApplicationController
def index
end
end
This will need a view to render so create one in app/views/controllers/marketing/index.html.erb
<%= render(Button::Component.new) %>
We also need to add our route in config/routes.rb
root "marketing#index"
When you start up the server again you should see our awesome component!
We will add a helper to make rendering components a bit simpler and easier on the eyes
Add the following to app/helpers/application_helper.rb
def component(name, *args, **kwargs, &block)
component = name.to_s.camelize.constantize::Component
render(component.new(*args, **kwargs), &block)
end
We can now update our component render code:
<%= component("Button") %>
This helper makes it easy to render nested components like this:
<%= component("Button/Nested/Item", prop: "Test") %>
Update the base class app/views/components/application_view_component.rb
to allow usage of helpers in the components:
class ApplicationViewComponent < ViewComponentContrib::Base
extend Dry::Initializer
include ApplicationHelper
end
First, install the js package esbuild-rails to easily load stimulus controllers:
yarn add esbuild-rails
Setup the esbuild.config.mjs
file:
#!/usr/bin/env node
// Requires esbuild 0.17+
//
// Esbuild is configured with 3 modes:
//
// `yarn build` - Build JavaScript and exit
// `yarn build --watch` - Rebuild JavaScript on change
// `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change
//
// Minify is enabled when "RAILS_ENV=production"
// Sourcemaps are enabled in non-production environments
import * as esbuild from "esbuild"
import path from "path"
import rails from "esbuild-rails"
import chokidar from "chokidar"
import http from "http"
import { setTimeout } from "timers/promises"
const clients = []
const entryPoints = [
"application.js"
]
const watchDirectories = [
"./app/javascript/**/*.js",
"./app/views/**/*.html.erb",
"./app/assets/builds/**/*.css", // Wait for cssbundling changes
]
const config = {
absWorkingDir: path.join(process.cwd(), "app/javascript"),
bundle: true,
entryPoints: entryPoints,
minify: process.env.RAILS_ENV == "production",
outdir: path.join(process.cwd(), "app/assets/builds"),
plugins: [rails()],
sourcemap: process.env.RAILS_ENV != "production"
}
async function buildAndReload() {
// Foreman & Overmind assign a separate PORT for each process
const port = parseInt(process.env.PORT)
const context = await esbuild.context({
...config,
banner: {
js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`,
}
})
// Reload uses an HTTP server as an even stream to reload the browser
http
.createServer((req, res) => {
return clients.push(
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
Connection: "keep-alive",
})
)
})
.listen(port)
await context.rebuild()
console.log("[reload] initial build succeeded")
let ready = false
chokidar
.watch(watchDirectories)
.on("ready", () => {
console.log("[reload] ready")
ready = true
})
.on("all", async (event, path) => {
if (ready === false) return
if (path.includes("javascript")) {
try {
await setTimeout(20)
await context.rebuild()
console.log("[reload] build succeeded")
} catch (error) {
console.error("[reload] build failed", error)
}
}
clients.forEach((res) => res.write("data: update\n\n"))
clients.length = 0
})
}
if (process.argv.includes("--reload")) {
buildAndReload()
} else if (process.argv.includes("--watch")) {
let context = await esbuild.context({...config, logLevel: 'info'})
context.watch()
} else {
esbuild.build(config)
}
Update our package.json build command:
"build": "node esbuild.config.mjs",
Let's automatically load stimulus controllers from our components directory by adding the code to app/javascript/controllers/application.js
// ViewComponents in app/views/components
import viewComponentControllers from "./../../views/components/**/controller.js"
viewComponentControllers.forEach((controller) => {
const { name, module } = controller
// Tidy up controller name
// ..--..--views--components--video-reviewer--controller.js => video-reviewer
const controllerName = name
.replaceAll('..--..--views--components--', '')
.replaceAll('--controller.js', '')
application.register(controllerName, module.default)
})
Now we can get in to the fun part!
Open up the app/views/components/button/component.html.erb
<button
class="
bg-blue-500
font-bold
px-4
py-2
rounded-lg
text-white
"
>
<%= content %>
</button>
This is cool but what if we want to support variants?
Add the tailwind_merge gem:
bundle add tailwind_merge
Update tailwind.config.js
to watch our component.rb files
module.exports = {
content: [
'./app/assets/stylesheets/**/*.css',
'./app/helpers/**/*.rb',
'./app/javascript/**/*.js',
'./app/views/**/*.html.erb',
'./app/views/components/**/*.js',
'./app/views/components/**/*.rb',
]
}
Update our app/views/components/application_view_component.rb
file:
class ApplicationViewComponent < ViewComponentContrib::Base
extend Dry::Initializer
include ApplicationHelper
include ViewComponentContrib::StyleVariants
style_config.postprocess_with do |classes|
TailwindMerge::Merger.new.merge(classes.join(" "))
end
end
Let's move our classes to the app/views/components/button/component.rb
file:
# frozen_string_literal: true
class Button::Component < ApplicationViewComponent
option :variant, default: proc { :default }
# https://github.com/palkan/view_component-contrib#style-variants
style do
base {
%w[
bg-blue-500
font-bold
px-4
py-2
rounded-lg
text-white
]
}
# The "compound" directive allows us to declare additional classes to add
# when the provided combination is used
# compound(variant: :outline, disabled: true) {
# %w[
# opacity-75
# bg-slate-300
# ]
# }
end
end
Then we can update our component.html.erb
class to this:
<button
class="<%= style() %>"
>
<%= content %>
</button>
If we refresh the page the button should still render correctly.
Let's add a variant called variant
(yes that's a bit weird but roll with it!):
# frozen_string_literal: true
class Button::Component < ApplicationViewComponent
option :variant, default: proc { :primary }
# https://github.com/palkan/view_component-contrib#style-variants
style do
base {
%w[
font-bold
px-4
py-2
rounded-lg
]
}
variants {
variant {
primary {
%w[
bg-blue-500
text-white
]
}
outline {
%w[
border
border-blue-500
text-blue-500
]
}
ghost {
%w[
text-blue-500
]
}
}
}
defaults {
{
variant: :primary,
}
}
# The "compound" directive allows us to declare additional classes to add
# when the provided combination is used
# compound(variant: :outline, disabled: true) {
# %w[
# opacity-75
# bg-slate-300
# ]
# }
end
end
Now we can update the html:
<button
class="<%= style(variant:) %>"
>
<%= content %>
</button>
We won't be doing anything crazy here but I just want to show you how easy it is!
Update app/helpers/application_helper.rb
module ApplicationHelper
def component(name, *args, **kwargs, &block)
component = name.to_s.camelize.constantize::Component
render(component.new(*args, **kwargs), &block)
end
def component_identifier(name)
component = "#{name.to_s.camelize}::Component".constantize
component.identifier
end
end
Add the methods too our ApplicationViewComponent file app/views/components/application_view_component.rb
class ApplicationViewComponent < ViewComponentContrib::Base
extend Dry::Initializer
include ApplicationHelper
include ViewComponentContrib::StyleVariants
style_config.postprocess_with do |classes|
TailwindMerge::Merger.new.merge(classes.join(" "))
end
class << self
def component_name
@component_name ||= name.sub(/::Component$/, "").underscore
end
def identifier
@identifier ||= component_name.gsub("_", "-").gsub("/", "--")
end
def self.identifier
identifier
end
end
end
Let's create our controller at app/views/components/button/controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect(){
console.log("Connected", this.element)
}
triggerAlert(e){
const { target: { dataset: { message } } } = e
alert(message)
}
}
Link the controller to our component:
<button
class="<%= style(variant:) %>"
data-action="click-><%= component_identifier("Button") %>#triggerAlert"
data-controller="<%= component_identifier("Button") %>"
data-message="<%= alert_message %>"
>
<%= content %>
</button>
Add the option to the component.rb
option :alert_message, default: proc { "Hello World" }
We should now see the connected log message in our console and when you click the button the alert should show.
And that's it! We now have components running in rails that can be connected to Stimulus to add javascript functionality as well as styling variants with tailwind.