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 asynchronously posts_future = fetch_recent_posts.load_async comments_future = fetch_pending_comments.load_async analytics_future = ActiveRecord::Base.async_exec { fetch_analytics_data } notifications_future = fetch_user_notifications.load_async # Resolve the futures posts = posts_future.value comments = comments_future.value analytics = analytics_future.value notifications = notifications_future.value 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 (2)
uninitialized constant ActiveRecord::Future
Hey Thanks it will be coming in 8.1. But I have improved the code using
load_async
which is working in 8.0