When our team decided to upgrade to Rails 8, we chose a more Rails-native approach using importmap for JavaScript management. This decision aligned perfectly with Rails' philosophy of convention over configuration, and I'm excited to share how this choice shaped our development experience.
Initial Setup and Modern Stack Choices
Let's start with setting up our Rails 8 project:
rails new modern_platform \
--css tailwind \
--database postgresql \
--skip-test \
--skip-system-test
Why no --javascript
flag? Rails 8 comes with importmap by default, which I've found to be a game-changer for managing JavaScript dependencies. Here's how we configured our importmap:
# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
# Third-party packages we're using
pin "chart.js", to: "https://ga.jspm.io/npm:chart.js@4.4.1/dist/chart.js"
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.9/src/index.js"
pin "trix"
pin "@rails/actiontext", to: "actiontext.js"
# Local JavaScript modules
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/components", under: "components"
Modern JavaScript Organization
One of the benefits of importmap is how naturally it fits with module-based JavaScript. Here's how we structure our JavaScript:
// app/javascript/controllers/post_form_controller.js
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
export default class extends Controller {
static targets = ["form", "preview"]
static values = {
previewUrl: String
}
async preview() {
const formData = new FormData(this.formTarget)
try {
const response = await post(this.previewUrlValue, {
body: formData
})
if (response.ok) {
const html = await response.text
this.previewTarget.innerHTML = html
}
} catch (error) {
console.error("Preview failed:", error)
}
}
}
Component-Based Architecture
We've embraced ViewComponent with Stimulus, creating a powerful combination for reusable UI components:
# app/components/rich_text_editor_component.rb
class RichTextEditorComponent < ViewComponent::Base
attr_reader :form, :field
def initialize(form:, field:)
@form = form
@field = field
end
def stimulus_controller_options
{
data: {
controller: "rich-text-editor",
rich_text_editor_toolbar_value: toolbar_options.to_json
}
}
end
private
def toolbar_options
{
items: [
%w[bold italic underline strike],
%w[heading-1 heading-2],
%w[link code],
%w[unordered-list ordered-list]
]
}
end
end
<!-- app/components/rich_text_editor_component.html.erb -->
<div class="rich-text-editor" <%= stimulus_controller_options %>>
<%= form.rich_text_area field,
class: "prose max-w-none",
data: {
rich_text_editor_target: "editor",
action: "trix-change->rich-text-editor#onChange"
} %>
<div class="mt-2 text-sm text-gray-500"
data-rich-text-editor-target="counter">
0 characters
</div>
</div>
// app/javascript/controllers/rich_text_editor_controller.js
import { Controller } from "@hotwired/stimulus"
import Trix from "trix"
export default class extends Controller {
static targets = ["editor", "counter"]
static values = {
toolbar: Object,
maxLength: Number
}
connect() {
this.setupToolbar()
this.updateCounter()
}
onChange() {
this.updateCounter()
}
updateCounter() {
const text = this.editorTarget.value
this.counterTarget.textContent =
`${text.length} characters`
}
setupToolbar() {
if (!this.hasToolbarValue) return
const toolbar = this.editorTarget
.querySelector("trix-toolbar")
// Customize toolbar based on configuration
this.toolbarValue.items.forEach(group => {
// Toolbar customization logic
})
}
}
Chart.js Integration with Importmap
Here's how we handle data visualization using Chart.js through importmap:
// app/javascript/controllers/analytics_chart_controller.js
import { Controller } from "@hotwired/stimulus"
import { Chart } from "chart.js"
export default class extends Controller {
static values = {
data: Object,
options: Object
}
connect() {
this.initializeChart()
}
initializeChart() {
const ctx = this.element.getContext("2d")
new Chart(ctx, {
type: "line",
data: this.dataValue,
options: {
...this.defaultOptions,
...this.optionsValue
}
})
}
get defaultOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom"
}
}
}
}
}
Performance Optimizations with HTTP/2
One advantage of importmap is its excellent performance with HTTP/2. Here's how we optimize our asset delivery:
# config/environments/production.rb
Rails.application.configure do
# Use CDN for importmapped JavaScript
config.action_controller.asset_host = ENV["ASSET_HOST"]
# Enable HTTP/2 Early Hints
config.action_dispatch.early_hints = true
# Configure importmap hosts
config.importmap.cache_sweepers << Rails.root.join("app/javascript")
# Preload critical JavaScript
config.action_view.preload_links_header = true
end
Testing JavaScript Components
We use Capybara with Cuprite for JavaScript testing:
# spec/system/posts_spec.rb
RSpec.describe "Posts", type: :system do
before do
driven_by(:cuprite)
end
it "previews post content", js: true do
visit new_post_path
find("[data-controller='post-form']").tap do |form|
form.fill_in "Content", with: "**Bold text**"
form.click_button "Preview"
expect(form).to have_css(
".preview strong",
text: "Bold text"
)
end
end
end
Deployment Considerations
Our production setup leverages HTTP/2 and CDN caching:
# nginx.conf
server {
listen 443 ssl http2;
# Enable asset caching
location /assets/ {
expires max;
add_header Cache-Control public;
}
# Early hints for importmapped JavaScript
location / {
proxy_pass http://backend;
http2_push_preload on;
}
}
Parallel Query Execution: A Game-Changer
One of the most exciting features we discovered in Rails 8 was parallel query execution. During our performance optimization sprint, this became a crucial tool for handling complex dashboard pages:
# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
def show
# Execute multiple queries concurrently
posts, comments, analytics, notifications = ActiveRecord::Future.all(
fetch_recent_posts,
fetch_pending_comments,
fetch_analytics_data,
fetch_user_notifications
)
respond_to do |format|
format.html do
render locals: {
posts: posts,
comments: comments,
analytics: analytics,
notifications: notifications
}
end
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("dashboard-posts", partial: "posts/list", locals: { posts: posts }),
turbo_stream.update("dashboard-analytics", partial: "analytics/summary", locals: { data: analytics })
]
end
end
end
private
def fetch_recent_posts
Post.visible_to(current_user)
.includes(:author, :categories)
.order(published_at: :desc)
.limit(10)
end
def fetch_pending_comments
Comment.pending_review
.includes(:post, :author)
.where(post: { author_id: current_user.id })
.limit(15)
end
def fetch_analytics_data
AnalyticsService.fetch_dashboard_metrics(
user: current_user,
range: 30.days.ago..Time.current
)
end
def fetch_user_notifications
current_user.notifications
.unread
.includes(:notifiable)
.limit(5)
end
end
To make this even more powerful, we integrated it with Stimulus for real-time updates:
// app/javascript/controllers/dashboard_controller.js
import { Controller } from "@hotwired/stimulus"
import { Chart } from "chart.js"
export default class extends Controller {
static targets = ["analytics", "posts"]
connect() {
this.initializeCharts()
this.startRefreshTimer()
}
disconnect() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
}
}
async refresh() {
try {
const response = await fetch(this.element.dataset.refreshUrl, {
headers: {
Accept: "text/vnd.turbo-stream.html"
}
})
if (response.ok) {
Turbo.renderStreamMessage(await response.text())
}
} catch (error) {
console.error("Dashboard refresh failed:", error)
}
}
startRefreshTimer() {
this.refreshTimer = setInterval(() => {
this.refresh()
}, 30000) // Refresh every 30 seconds
}
initializeCharts() {
if (!this.hasAnalyticsTarget) return
const data = JSON.parse(this.analyticsTarget.dataset.metrics)
this.createAnalyticsChart(data)
}
createAnalyticsChart(data) {
const ctx = this.analyticsTarget.getContext("2d")
new Chart(ctx, {
type: "line",
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
animations: {
tension: {
duration: 1000,
easing: 'linear'
}
}
}
})
}
}
The combination of parallel queries and Turbo Streams gave us impressive performance improvements:
- Dashboard load times dropped by 47%
- Database connection usage became more efficient
- Real-time updates felt smoother with optimistic UI updates
Learning Journey and Trade-offs
Moving to importmap wasn't without challenges. Here's what we learned:
- Simplified Dependency Management: No more yarn/npm complexity
- Better Caching: HTTP/2 multiplexing improved load times
- Module Patterns: Encouraged cleaner JavaScript organization
- Development Experience: Faster feedback loop without build steps
Looking Forward
Rails 8 with importmap has transformed our development workflow. The native integration with Hotwire and Stimulus, combined with HTTP/2 optimizations, has given us a modern, maintainable, and performant application stack.
Stay tuned for more articles on our Rails 8 journey. Feel free to reach out with questions or share your own importmap experiences!
Happy Coding!
Originally published at https://sulmanweb.com
Top comments (0)