Let me share with you some practical ways I handle JavaScript in Rails applications today. The landscape has changed significantly. We’ve moved from sprinkling jQuery everywhere to a more thoughtful approach. What I’ve found works best is mixing Rails’ conventions with modern JavaScript practices. Here are some approaches that have served me well.
The first approach involves using import maps. This lets you work with JavaScript packages directly in the browser without a separate build step. Think of it like telling your browser exactly where to find each piece of JavaScript your application needs. You create a simple configuration file that maps friendly names to actual JavaScript files.
# config/importmap.rb
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin "lodash", to: "https://ga.jspm.io/npm:[email protected]/lodash.js"
The pin statements declare your dependencies. The browser handles loading them when they’re needed. For local files, pin_all_from automatically maps everything in a directory. The preload: true option tells the browser to download critical files early. This approach keeps things simple, especially for applications that don’t need complex bundling.
Then there’s Stimulus, a modest framework that feels natural with Rails. It doesn’t try to control your entire frontend. Instead, it adds behavior to your existing HTML. You create small controllers that connect to specific parts of your page.
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static classes = ["open"]
toggle() {
this.menuTarget.classList.toggle(this.openClass)
}
hide(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.classList.remove(this.openClass)
}
}
}
<!-- In your view -->
<div data-controller="dropdown"
data-action="click@window->dropdown#hide">
<button data-action="click->dropdown#toggle">
Menu
</button>
<div data-dropdown-target="menu"
data-dropdown-open-class="is-open">
<!-- Menu content -->
</div>
</div>
The controller connects to the HTML using data-controller. Targets find specific elements within the controller’s scope. Actions connect DOM events to controller methods. Values and classes pass configuration from your Rails views. This keeps your JavaScript focused and close to where it’s used.
For more complex logic, I create plain JavaScript modules. These are just ES6 modules that handle specific tasks, separate from any framework. They’re easy to test and reason about.
// app/javascript/services/price_calculator.js
export function calculatePrice(basePrice, options = {}) {
let price = basePrice
if (options.quantity > 10) {
price *= 0.9 // 10% bulk discount
}
if (options.customerType === 'premium') {
price *= 0.85 // 15% premium discount
}
return Math.round(price * 100) / 100
}
export function formatPrice(price, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(price)
}
// Using it in a Stimulus controller
import { Controller } from "@hotwired/stimulus"
import { calculatePrice, formatPrice } from "../services/price_calculator"
export default class extends Controller {
updatePrice() {
const basePrice = parseFloat(this.element.dataset.basePrice)
const quantity = parseInt(this.quantityTarget.value)
const customerType = this.customerTypeTarget.value
const price = calculatePrice(basePrice, { quantity, customerType })
this.priceTarget.textContent = formatPrice(price)
}
}
This separation keeps your business logic independent of your presentation logic. You can change your frontend framework without rewriting your price calculations. The modules are just plain JavaScript functions that do one thing well.
Not all JavaScript needs to load immediately. Lazy loading lets you delay loading code until it’s actually needed. This can significantly improve your initial page load time.
// Load a heavy charting library only when needed
async function loadChartLibrary() {
const { Chart, Chartist } = await import(
/* webpackChunkName: "charts" */
'./chart_library'
)
return { Chart, Chartist }
}
// Use it conditionally
document.addEventListener('turbo:load', async () => {
const chartElements = document.querySelectorAll('[data-chart]')
if (chartElements.length > 0) {
const { Chart } = await loadChartLibrary()
chartElements.forEach(element => {
new Chart(element, {
type: element.dataset.chartType,
data: JSON.parse(element.dataset.chartData)
})
})
}
})
// Another example: loading based on user interaction
document.addEventListener('click', async (event) => {
if (event.target.matches('[data-load-maps]')) {
const { MapViewer } = await import('./map_viewer')
new MapViewer(event.target).init()
}
})
The import() function loads modules dynamically. Webpack automatically splits these into separate files. The browser only downloads them when the import function runs. This works especially well for features not everyone uses, like admin interfaces or complex visualizations.
For applications that need more traditional bundling, Webpacker (or its successors) still has its place. It handles transpilation, minification, and code splitting in a more automated way.
# config/webpacker.yml
default: &default
source_path: app/javascript
source_entry_path: packs
public_root_path: public
public_output_path: packs
extensions:
- .js
- .jsx
- .ts
- .vue
- .sass
# app/javascript/packs/application.js
import '../styles/application.scss'
import { Application } from "@hotwired/stimulus"
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
const application = Application.start()
const context = require.context("../controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
// Dynamic imports for code splitting
import(/* webpackChunkName: "admin" */ './admin').then(module => {
// Admin panel loaded
})
// In your layout
<%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>
Webpacker manages the build process, transpiling modern JavaScript for older browsers. It can process various file types through loaders. The pack tags include the built files in your pages. While it adds complexity, it provides more control over the final output.
Turbo Streams offer a different approach for real-time updates. They let your server push HTML updates to connected clients over WebSockets.
# In a model
class Message < ApplicationRecord
belongs_to :chat_room
after_create_commit do
broadcast_append_to chat_room,
target: "messages",
partial: "messages/message",
locals: { message: self }
end
end
# In a controller
def create
@message = Message.new(message_params)
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @chat_room }
end
end
end
<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.append "messages" do %>
<%= render @message %>
<% end %>
<%= turbo_stream.update "new_message" do %>
<%= render "form", message: Message.new %>
<% end %>
<!-- In your view -->
<%= turbo_stream_from @chat_room %>
<div id="messages">
<%= render @chat_room.messages %>
</div>
<div id="new_message">
<%= render "form", message: Message.new %>
</div>
When a message is created, the broadcast_append_to sends a Turbo Stream to all clients subscribed to that chat room. The stream contains HTML that the browser automatically inserts into the page. This happens without any custom JavaScript on the client side. It’s like server-side rendered real-time updates.
Finally, for coordination between different parts of your JavaScript, an event system can help. This lets components communicate without knowing about each other directly.
// app/javascript/events/emitter.js
class EventEmitter {
constructor() {
this.events = {}
}
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(callback)
// Return a function to remove this listener
return () => {
this.off(eventName, callback)
}
}
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(
cb => cb !== callback
)
}
}
emit(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(callback => {
try {
callback(data)
} catch (error) {
console.error(`Error in ${eventName} handler:`, error)
}
})
}
}
}
export const emitter = new EventEmitter()
// In a shopping cart component
import { emitter } from '../events/emitter'
class ShoppingCart {
addItem(item) {
// Add to cart logic...
// Notify other components
emitter.emit('cart:item_added', {
item,
cartTotal: this.getTotal(),
itemCount: this.getItemCount()
})
}
}
// In a notification component
import { emitter } from '../events/emitter'
class Notifications {
constructor() {
this.removeCartListener = emitter.on(
'cart:item_added',
this.showAddedNotification.bind(this)
)
}
showAddedNotification(data) {
this.show(`${data.item.name} was added to your cart`)
}
cleanup() {
this.removeCartListener()
}
}
The emitter acts as a central communication hub. Components can listen for events they care about. When something happens, like adding an item to a cart, the cart emits an event. Any component listening for that event can react. This keeps components loosely coupled. They don’t need to know about each other directly.
Each of these approaches solves different problems. Import maps simplify dependency management. Stimulus adds behavior to HTML. Plain modules organize business logic. Lazy loading improves performance. Webpacker handles complex builds. Turbo Streams enable real-time updates. Event systems coordinate components.
I don’t use all of them in every project. For a simple internal tool, import maps and Stimulus might be enough. For a complex web application, I might use several together. The key is understanding what each approach offers and choosing what fits your needs.
Remember that these patterns work within Rails’ conventions. They don’t require you to abandon what makes Rails productive. Instead, they extend Rails to handle modern JavaScript requirements. They let you add interactivity where it’s needed without overwhelming complexity.
Start simple. Add complexity only when you need it. Your future self will thank you when the code remains understandable months later. JavaScript in Rails doesn’t have to be a struggle. With these patterns, you can build interactive applications that are maintainable and enjoyable to work on.