Upgrading from Rails 6.x Webpacker to Rails 7 Importmaps
For the past several months, I've been navigating the complex journey of upgrading my Rails 6.1.x applications to Rails 7. This wasn't just a simple version bump—it involved significant architectural changes, particularly in how JavaScript and React components are handled. I experimented with various approaches, creating test applications using ESbuild, Importmaps, and even keeping Webpacker. While ESbuild offered a straightforward path (essentially using node modules without the Webpacker overhead), I ultimately chose Importmaps for its simplicity and alignment with my React-based frontend architecture.
The migration process was both challenging and enlightening. I had to refactor substantial portions of my codebase, particularly my React components and CSS approach. I was gradually transitioning from a custom CSS framework to Tailwind CSS, which meant dealing with a mix of legacy styles and new utility classes. While I won't claim to remember every single step of the process, I'll outline the key phases of my upgrade journey. This approach worked for me, though your mileage may vary depending on your specific application needs.
Creating a Rails 7 Branch
The first step was to create a dedicated branch for the Rails 7 upgrade. This isolation strategy allowed me to experiment freely without affecting my production code. While I didn't need to modify my deployed master branch during development, the eventual merge strategy was always in the back of my mind.
I initiated the upgrade process with bin/rails app:update
, which automatically updated various configuration files. I should mention that my applications don't have comprehensive test coverage—I've attempted to adopt testing practices multiple times but haven't fully embraced them yet. Instead, I've relied on my traditional development approach: make changes, deploy to a staging environment, and verify functionality manually.
The initial update process went relatively smoothly. The sass-rails
gem remained in my Gemfile, which helped maintain compatibility with my existing stylesheets.
However, I encountered an unexpected issue with ActiveStorage migrations. My applications never utilized ActiveStorage, and I had no intention of adding it. To bypass this obstacle, I temporarily set config.active_record.migration_error = false
in my configuration. Later, I refined this approach by replacing the generic require "rails/all"
with specific requires that excluded ActiveStorage, following the Rails documentation recommendations.
With these initial hurdles behind me, I was ready to tackle the most significant change: removing Webpacker and configuring the application for Rails 7's Importmaps.
Removing Webpacker
The prospect of removing Webpacker seemed daunting at first, but the process was surprisingly straightforward. I systematically removed the following files and directories:
- JavaScript entry points and configuration files
- Webpacker-specific configuration
- Package management files
- Build tool executables
This included removing:
- app/javascript/application.css
- app/javascript/channels/consumer.js
- app/javascript/channels/index.js
- app/javascript/packs/application.js
- babel.config.js
- bin/spring
- bin/webpack
- bin/webpack-dev-server
- bin/yarn
- config/webpack/development.js
- config/webpack/environment.js
- config/webpack/production.js
- config/webpack/test.js
- config/webpacker.yml
- package-lock.json
- postcss.config.js
- yarn.lock
Next, I updated my Gemfile to align with Rails 7's recommended approach. Here's the core set of gems I included (excluding application-specific dependencies):
gem "rails", "~> 7.0.0"
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", "~> 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails]
gem "tailwindcss-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
gem "redis", "~> 4.0"
After these changes, my development environment was functional, though I encountered some CSS-related issues that needed addressing.
React and JavaScript Migration
My applications had accumulated a significant amount of legacy SCSS over the years. Identifying which styles were actively used versus which were obsolete proved challenging. I focused on preserving styles for colors, buttons, tables, and a few custom components. To convert my SCSS to standard CSS, I utilized online conversion tools and integrated the resulting CSS into my application.
For JavaScript and React, I needed to update my import statements to use the new @hotwired version. The key change was updating the import statements from:
import React from 'react'
import ReactDOM from 'react-dom'
to:
import React from 'https://ga.jspm.io/npm:react@18.2.0/index.js'
import ReactDOM from 'https://ga.jspm.io/npm:react-dom@18.2.0/index.js'
I then configured my importmap.rb file to include the necessary CDN references for React and other libraries:
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "react", to: "https://ga.jspm.io/npm:react@18.2.0/index.js"
pin "react-dom", to: "https://ga.jspm.io/npm:react-dom@18.2.0/index.js"
pin "react-router-dom", to: "https://ga.jspm.io/npm:react-router-dom@6.10.0/index.js"
pin "axios", to: "https://ga.jspm.io/npm:axios@1.3.6/index.js"
pin "flatpickr", to: "https://ga.jspm.io/npm:flatpickr@4.6.9/dist/flatpickr.js"
For third-party libraries like Flatpickr, I added the necessary stylesheet link to my layout:
<link
rel="stylesheet"
href="https://ga.jspm.io/npm:flatpickr@4.6.9/dist/flatpickr.min.css"
/>
The CSS bundling process presented some challenges, particularly with the Tailwind configuration. I merged my existing Tailwind configuration with the new Rails 7 defaults:
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
content: [
'./app/helpers/**/*.rb',
'./app/javascript/**/*.js',
'./app/views/**/*.html.*',
'./app/components/**/*.html.*',
'./app/javascript/components/**/*.jsx',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},
colors: {
orange: '#ffa500',
malt: '#991A1E',
gold: '#A79055',
'dark-blue': '#0F3E61',
success: '#63ed7a',
secondary: '#9db3b8',
w3green: '#4CAF50',
w3red: '#f44336',
'blue-link': '#00c',
lime: {
lightest: '#f1fff1',
lighter: '#e2ffe2',
light: '#c9ffc9',
DEFAULT: '#b8ffb8',
dark: '#96ff96',
darker: '#7cff7c',
darkest: '#49ff49',
},
green: {
lighter: 'hsla(122, 59%, 64%, 1)',
light: 'hsla(122, 49%, 54%, 1)',
DEFAULT: 'hsla(122, 39%, 49%, 1)',
dark: 'hsla(122, 39%, 39%, 1)',
darker: 'hsla(122, 39%, 29%, 1)',
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/typography'),
],
}
I also needed to ensure that my React components directory was included in the content array for proper Tailwind processing.
One of the more significant changes involved converting my Tailwind components that used the @apply directive to React components. This approach provided better maintainability and consistency. Here's an example of how I structured these components:
// Button.jsx
import React from 'react'
export const Button = ({
children,
variant = 'primary',
onClick,
className = '',
type = 'button',
}) => {
const baseClasses =
'py-1 px-2 text-black hover:text-white rounded font-lg font-bold'
const variantClasses = {
primary: 'bg-blue-400 text-blue-link hover:text-blue-100',
warning: 'bg-orange hover:text-yellow-200',
success: 'bg-green-500 hover:text-green-100',
danger: 'bg-red-500 hover:text-red-200',
secondary: 'bg-secondary',
}
return (
<button
type={type}
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
onClick={onClick}
>
{children}
</button>
)
}
// Alert.jsx
import React from 'react'
export const Alert = ({ type = 'info', children }) => {
const alertClasses = {
danger: 'bg-red-200 text-red-600',
info: 'bg-blue-200 text-blue-600',
success: 'bg-green-200 text-green-600',
warning: 'bg-yellow-400 text-yellow-800',
default: 'bg-gray-200 text-gray-600',
}
return <div className={`rounded p-4 ${alertClasses[type]}`}>{children}</div>
}
// ConfirmDelete.jsx
import React, { useState } from 'react'
export const ConfirmDelete = ({
model,
onDelete,
confirmMessage = 'Are you sure?',
prompt = null,
}) => {
const [isConfirming, setIsConfirming] = useState(false)
const handleConfirm = () => {
const confirmed = window.confirm(confirmMessage)
if (confirmed) {
onDelete(model)
}
setIsConfirming(false)
}
return (
<div className="inline-block">
<button
className="rounded bg-red-500 px-2 py-1 text-white hover:bg-red-600"
onClick={() => setIsConfirming(true)}
>
{prompt || `Delete ${model.className || 'item'}`}
</button>
{isConfirming && (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="rounded-lg bg-white p-6 shadow-lg">
<p className="mb-4">{confirmMessage}</p>
<div className="flex justify-end space-x-2">
<button
className="rounded bg-gray-300 px-4 py-2 hover:bg-gray-400"
onClick={() => setIsConfirming(false)}
>
Cancel
</button>
<button
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
onClick={handleConfirm}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}
The ConfirmDelete
component addressed a specific challenge with Rails 7's handling of delete actions. The framework changed from using link_to
with method: :delete
to button_to
, but this change removed the confirmation dialog functionality. My React component restored this functionality while maintaining the new button-based approach. This was particularly important for critical operations where accidental clicks could have serious consequences.
To integrate these React components with Rails, I created a helper method:
module ReactHelper
def react_component(name, props = {}, html_options = {})
content_tag(
"div",
"",
{
data: {
controller: "react",
react_component_value: name,
react_props_value: props.to_json
},
class: html_options[:class]
}
)
end
end
And a corresponding Stimulus controller to mount the React components:
import { Controller } from '@hotwired/stimulus'
import React from 'react'
import ReactDOM from 'react-dom'
export default class extends Controller {
static values = {
component: String,
props: Object,
}
connect() {
this.mountComponent()
}
disconnect() {
ReactDOM.unmountComponentAtNode(this.element)
}
mountComponent() {
const componentName = this.componentValue
const props = this.propsValue || {}
// Dynamically import the component based on name
import(`../components/${componentName}`).then((module) => {
const Component = module.default || module[componentName]
ReactDOM.render(React.createElement(Component, props), this.element)
})
}
}
As a side project, I experimented with a Ruby class to generate CSS from my Tailwind configuration. While I never completed or deployed this solution, it was an interesting exercise in bridging the gap between my Tailwind configuration and traditional CSS:
class TwColors
attr_accessor :colors, :css
Colors = {:colors=>
{:orange=>"#ffa500",
:malt=>"#991A1E",
:gold=>"#A79055",
:"dark-blue"=>"#0F3E61",
:lime=>
{:lightest=>"#f1fff1",
:lighter=>"#e2ffe2",
:light=>"#c9ffc9",
:DEFAULT=>"#b8ffb8",
:dark=>"#96ff96",
:darker=>"#7cff7c",
:darkest=>"#49ff49"},
:green=>
{:lighter=>"hsla(122, 59%, 64%, 1)",
:light=>"hsla(122, 49%, 54%, 1)",
:DEFAULT=>"hsla(122, 39%, 49%, 1)",
:dark=>"hsla(122, 39%, 39%, 1)",
:darker=>"hsla(122, 39%, 29%, 1)"}}}
def initialize()
@colors = Colors
@css = "not parsed yet \n"
@colors[:colors].each do |k,v|
unless v.is_a?(Hash)
add_color_classes(k,v)
else
add_nested_color_classes(k,v)
end
end
puts @css
end
def add_color_classes(k,v)
@css << ".text-#{k} {\n\tcolor: #{v};\n}"
@css << ".bg-#{k} {\n\t background-color: #{v};\n}\n"
end
def add_nested_color_classes(k,v)
v.each do |kk,vv|
if kk.to_s == "DEFAULT"
add_color_classes("#{k}",vv)
else
add_color_classes("#{k}-#{kk}",vv)
end
end
end
end
Finalizing the Upgrade
The most nerve-wracking part of the process was merging the Rails 7 branch back into master. I was concerned that Capistrano deployment might fail after the merge. Despite extensive research, I couldn't find a reliable way to deploy a branch directly to my staging server using Capistrano.
Instead, I simulated a staging environment locally:
# Configure staging database to point to development
bin/rails assets:precompile
bin/rails s -e staging
During this testing phase, I identified and resolved issues with CSS classes not being properly included in components. After addressing these issues, the application functioned correctly.
I made sure to run bin/rails assets:clobber
when switching back to development to avoid any asset conflicts.
With everything working as expected, I proceeded with the merge. I created a backup of my repository as a precaution before executing the merge operation. While I use Fork on macOS for Git operations, I'm not particularly proficient with it, so I took extra care during this critical step.
The development environment continued to function properly after the merge, which was a positive sign.
To my relief, cap staging deploy
executed successfully without requiring any modifications to the Capistrano configuration.
After a few more refinements and testing cycles, I was ready to deploy to production. The deployment went smoothly, and I successfully transitioned all my applications to Rails 7 with Importmaps.
The upgrade process was challenging but ultimately rewarding. My applications now run on Rails 7 without the complexity and overhead of Webpacker, resulting in faster build times, simpler configuration, and a more maintainable codebase. The integration of React with Rails through Importmaps has provided a more streamlined development experience, allowing me to leverage the power of React components while maintaining the simplicity of Rails.