Setting Up Rails 7 with React Using Esbuild
Edu Depetris
- Jul 16, 2024- React
- Esbuild
- Ruby On Rails
In this article, I’ll explain how to set up Rails 7 with React using Esbuild without any extra gem dependencies other than the default jsbundling-rails. Additionally, I’ll create a view and controller helpers to enhance the developer experience.
Approach
Approach
I’d like to use React only to render a view or a component and Rails for everything else, keeping our React code scoped.
Starting a New Rails App with Esbuild
Starting a New Rails App with Esbuild
rails new app -j esbuild
yarn add react react-dom lodash-es
We could avoid adding lodash-es, but we would have to rewrite a few parts.
Generate a Home Page to Test React Elements
Generate a Home Page to Test React Elements
rails g controller home index
As mentioned before, I’d like to keep all of my React code in app/javascript/react
mkdir app/javascript/react
At this point, we have React installed along with Esbuild configured.
Adding a JS Helper Method to mount and render React Components
Create a new file at
app/javascript/react/application.js
inspired by this source:import React from "react"; import ReactDOM from "react-dom/client"; import { intersection, keys, assign, omit } from "lodash-es"; const CLASS_ATTRIBUTE_NAME = "data-react-class"; const PROPS_ATTRIBUTE_NAME = "data-react-props"; const ReactComponent = { registeredComponents: {}, componentRoots: {}, render(node, component) { const propsJson = node.getAttribute(PROPS_ATTRIBUTE_NAME); const props = propsJson && JSON.parse(propsJson); const reactElement = React.createElement(component, props); const root = ReactDOM.createRoot(node); root.render(reactElement); return root; }, registerComponents(components) { const collisions = intersection( keys(this.registeredComponents), keys(components) ); if (collisions.length > 0) { console.error( `Following components are already registered: ${collisions}` ); } assign(this.registeredComponents, omit(components, collisions)); return true; }, mountComponents() { const { registeredComponents } = this; const toMount = document.querySelectorAll(`[${CLASS_ATTRIBUTE_NAME}]`); for (let i = 0; i < toMount.length; i += 1) { const node = toMount[i]; const className = node.getAttribute(CLASS_ATTRIBUTE_NAME); const component = registeredComponents[className]; if (component) { if (node.innerHTML.length === 0) { const root = this.render(node, component); this.componentRoots = { ...this.componentRoots, [className]: root }; } } else { console.error( `Can not render a component that has not been registered: ${className}` ); } } }, setup(components = {}) { if (typeof window.ReactComponent === "undefined") { window.ReactComponent = this; } window.ReactComponent.registerComponents(components); window.ReactComponent.mountComponents(); }, }; export default ReactComponent;
This code provides an interface to register components via a setup method and then mount them.
Dive into the mount part: it will find all elements that contain data-react-class and data-react-props attributes from the DOM. Then, it will create a ReactDOM element and finally render it with its props
Example React Component and View
Create an example React component and call it from the view via the data attributes:
app/javascript/react/components/HelloReact.jsx
import React from "react"; const HelloReact = ({ message }) => ( <div> <h1>{message}</h1> <p>React is working!</p> </div> ); export default HelloReact;
app/views/home/index.html.erb
<div data-react-class="HelloReact" data-react-props="<%= {message: "Hello React"}.to_json %>"></div>
Register the Component
Let’s connect the React component with the HTML div using the setup method.
app/javascript/react/index.js
import ReactComponent from "./application" import HelloReact from "./components/HelloReact" ReactComponent.setup({HelloReact})
Running the React Code
Run
app/javascript/react/index.js
in the app default entry point.app/javascript/application.js
import "./react"
At this point, we’re able to use React in our Rails app 🎉. However, it would be good to have Ruby helpers that allow us to create a <div> with the React component name and the props we want to render. Let’s work on improving the developer experience.
Enhancing Developer Experience with Ruby Helpers
Create a Ruby method to take a component class name and props, returning a div with the necessary information.
app/lib/react_component.rb
class ReactComponent include ActionView::Helpers::TagHelper include ActionView::Helpers::TextHelper attr_accessor :name def initialize(name) @name = name end def render(props = {}, options = {}) tag = options.delete(:tag) || :div data = { data: { "react-class" => name, "react-props" => props.to_json } } content_tag(tag, nil, options.deep_merge(data)) end end
Then, create a view helper at
app/helpers/application_helper.rb
def react_component(component_name, props = {}, options = {}) ReactComponent.new(component_name).render(props, options) end
Update the HTML view to use the new helper:
app/views/home/index.html.erb
<%= react_component "HelloReact", message: "Hello React!" %>
🏁 You can stop here, but I’d like to share the ‘screen’ pattern I’ve been using and how it can improve the developer experience.
Sometimes, I replace the entire view with a React component. When this happens, I like to call this component a ‘Screen’ or a similar name to add semantic meaning. For this example, let’s use the name ‘screen,’ but you can choose your own. The key is to be semantic and consistent.
Using the “Screen” Pattern
Create a screen component at:
app/javascript/react/screens/HomeScreen/index.jsx
Using the “Screen” Pattern
Create a screen component at:
app/javascript/react/screens/HomeScreen/index.jsx
import React from "react"; import HelloReact from "../../components/HelloReact"; const HomeScreen = () => ( <div> <h1>Home#index</h1> <p>Find me in app/views/home/index.html.erb</p> <HelloReact message="Hello React!" /> </div> ); export default HomeScreen;
Now, register the HomeScreen component:
app/javascript/react/index.js
import ReactComponent from "./application" import HomeScreen from "./screens/HomeScreen" ReactComponent.setup({HomeScreen})
Update the view to render the screen:
app/views/home/index.html.erb
<%= react_component "HomeScreen" %>
At this point, HTML views like this tend to contain only a render method. Wouldn’t it be handy to render the screen directly from the controller?
We can use our ReactComponent directly from the controller and avoid creating a view file to render the screen. Let's do it!
Render from the Controller
Extend the Rails controller to render a React component directly:
config/initializers/react_component_renderer.rb
ActionController::Renderers.add :react_component do |component_name, options| props = options.fetch(:props, {}) tag_options = options.fetch(:tag_options, {}) html = ReactComponent.new(component_name).render(props, tag_options) render_options = options.merge(inline: html) render(render_options) end
Create a controller helper:
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base def render_react(component_name, props: {}, **options) render react_component: component_name, props: props, **options end end
Now we can use react_component from the Rails controller like this:
app/controllers/home_controller.rb
class HomeController < ApplicationController def index render_react "HomeScreen" end end
Finally, we can remove the
app/views/home/index.html.erb
as the view is now rendered from the controller.More DX
• Add live reloading for a more productive development experience. Here's an article
Pros
• No extra gem
• No extra library (except for lodash-es)
• React only for building user interfaces
Cons
Cons
• No hot module replacement (HMR)
Extra 🌶️
- You can gain more insights into different approaches, pros, and costs by reading this article from Thoughtbot.
- Reading this article by Ryan Bigg will clarify a lot and provide you with additional ideas.
- I took pieces of code and ideas from this plugin and this pull request
Happy Coding!