A pragmatic guide for adding React to an existing Rails application (and still use Hotwire)

Steve Polito

Although Turbo and Stimulus are incredibly effective for creating reactive, high-fidelity web applications, there are still times where you will need to reach for React. In our case, this happens when you’re required to build a feature that could easily be solved by an off-the-shelf solution thanks to React’s rich component library ecosystem.

However, you’re hesitant to reach for React because you just want to “sprinkle” it into a page or two. You don’t want to abandon Rails defaults or Hotwire, and you certainly don’t want to be bothered with configuring (or maintaining) a complicated build process.

For a long time, I thought that using React with Rails was an “all-or-nothing” proposition. I’m now realizing that the two can be integrated on a spectrum. On one end is something like using an API only Rails application to power Next.js. In the middle is something like Superglue or Inertia.js. And, on the other end, is the ability to add React to a page or two. This is what we’ll focus on today.

If you’re interested in learning how to integrate React and Rails on a new project, we’ve written about that too.

Our base

For the sake of this tutorial, we’ll be working with a default Rails application spun up simply with rails new. This means we’ll be starting with importmap-rails, Turbo and Stimulus.

Our application simply renders a list of events, and we’ve been tasked with rendering it as a calendar. We’ve decided to use React for this since it’s a solved problem thanks to FullCalendar.

An image of a list of events

Remove importmaps

The first thing we’ll need to do is remove importmaps-rails. Although we could keep it, it’s advisable to have a consistent way to compile and manage all our assets.

bundle remove importmap-rails

We’ll then need to remove or modify the following files to undo what was generated with the installation script. The diff should look something like this:

--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,4 +1,2 @@
 //= link_tree ../images
 //= link_directory ../stylesheets .css
-//= link_tree ../../javascript .js
-//= link_tree ../../../vendor/javascript .js
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,3 +0,0 @@
-// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
-import "@hotwired/turbo-rails"
-import "controllers"
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -14,7 +14,6 @@
     <link rel="icon" href="/icon.svg" type="image/svg+xml">
     <link rel="apple-touch-icon" href="/icon.png">
     <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
-    <%= javascript_importmap_tags %>
   </head>

   <body>
--- a/bin/importmap
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env ruby
-
-require_relative "../config/application"
-require "importmap/commands"
--- a/config/importmap.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# Pin npm packages by running ./bin/importmap
-
-pin "application"
-pin "@hotwired/turbo-rails", to: "turbo.min.js"
-pin "@hotwired/stimulus", to: "stimulus.min.js"
-pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
-pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep
deleted file mode 100644
index e69de29..0000000

Install jsbundling-rails

Now that we’ve removed importmap-rails, we need a new way to compile and manage our assets in a way that will also support React. Fortunately, this is solved by another tool in the Rails ecosystem via jsbundling-rails.

After much trial and error, I found esbuild to be the simplest bundler option, in that no additional steps were required to get React to work.

bundle add jsbundling-rails
bin/rails javascript:install:esbuild

Re-install Turbo and Stimulus

Now that we’ve updated our bundler, let’s add back Turbo and Stimulus so that they work with esbuild. All we need to do is re-run their installation scripts.

bin/rails turbo:install
bin/rails stimulus:install

Note that you may need to manually register existing controllers like so:

// app/javascript/controllers/index.js
import { application } from "./application"

import HelloController from "./hello_controller"
application.register("hello", HelloController)

Add React

Now that we have a mechanism to bundle React, let’s add it.

yarn add react react-dom

To ensure everything is properly wired, let’s add a simple component and render it to the screen.

// app/javascript/components/app.jsx
import React from "react";
import { createRoot } from "react-dom/client";

// Clear the existing HTML content
document.body.innerHTML = '<div id="app"></div>';

// Render your React component instead
const root = createRoot(document.getElementById("app"));
root.render(<h1>Hello, world</h1>);
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,2 +1,3 @@
 import "@hotwired/turbo-rails"
 import "./controllers"
+import "./components/app"

If you run bin/dev you should see an updated homepage.

An image of "Hello, world" printed to the screen

Sprinkle in some React

Now that we’ve confirmed React works, let’s address the original requirement by adding a calendar. For this tutorial, we’ll leverage FullCalendar.

yarn add @fullcalendar/core \
 @fullcalendar/react \
 @fullcalendar/daygrid

Rather than create an API for the component to digest, let’s just return the @events as JSON directly on the page. I recognize that this may not be appropriate for all use cases, but if you have a small record set, this is a perfectly reasonable (dare I say preferable) approach.

<%# app/views/events/index.html.erb %>

<script id="events" type="application/json">
  <%= raw @events.to_json %>
</script>

<div id="app"><div>

Now that we’ve added our data source and an element for React to mount on, we can create a component for our calendar.

// app/javascript/components/calendar.jsx

import React, { useState, useEffect } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";

export default function Calendar() {
  const [events, setEvents] = useState([]);

  useEffect(() => {
    const scriptTag = document.getElementById("events");

    if (scriptTag) {
      const data = JSON.parse(scriptTag.textContent.trim());
      setEvents(data);
    }
  }, []);
  return (
    <FullCalendar
      plugins={[dayGridPlugin]}
      initialView="dayGridMonth"
      weekends={true}
      events={events}
    />
  );
}

Now we just need to update our base app.

--- a/app/javascript/components/app.jsx
+++ b/app/javascript/components/app.jsx
@@ -1,9 +1,14 @@
 import React from "react";
 import { createRoot } from "react-dom/client";
+import Calendar from "./calendar";

-// Clear the existing HTML content
-document.body.innerHTML = '<div id="app"></div>';
+export default function App() {
+  return <Calendar />;
+}

-// Render your React component instead
-const root = createRoot(document.getElementById("app"));
-root.render(<h1>Hello, world</h1>);
+const app = document.getElementById("app")
+
+if (app) {
+  const root = createRoot(app);
+  root.render(<App />);
+}

If you reload the homepage, you should see the calendar. However, if we navigate back and forth from the page, we’ll see the calendar disappears.

Navigating between pages wipes the calendar clean

Account for Turbo Drive

The reason the calendar disappears is because we’re not creating a full-page refresh when we navigate back and forth from the page. Instead, Turbo Drive is replacing the contents of the requesting document’s <body> with the contents of the response document’s <body>. Since the calendar is generated client-side, it’s not part of the response.

In order to account for this, we can simply listen for the turbo:load event before mounting our calendar. Then, we can listen for the turbo:before-visit event to unmount our calendar as we navigate away from the page.

--- a/app/javascript/components/app.jsx
+++ b/app/javascript/components/app.jsx
@@ -6,9 +6,15 @@ export default function App() {
   return <Calendar />;
 }

-const app = document.getElementById("app");
+document.addEventListener("turbo:load", () => {
+  const app = document.getElementById("app");

-if (app) {
-  const root = createRoot(app);
-  root.render(<App />);
-}
+  if (app) {
+    const root = createRoot(app);
+    root.render(<App />);
+
+    document.addEventListener("turbo:before-visit", () => {
+      root.unmount();
+    });
+  }
+});

Now, if we navigate back-and-forth we’ll see that the calendar loads as expected.

Navigating between pages persists the calendar

Wrapping up

So, what did we accomplish? Well, a lot. We were able to quickly and effectively introduce React to our Rails application without having to introduce a complicated build process or craft an API. Best of all, we now have a foundation to add additional React components if needed while still being able to use Turbo and Stimulus for everything else.

By the way, if you resonated with some of the concepts and approaches in the article, you might enjoy Superglue.