From Rails to JavaScript: A spectrum of integration options

When determining how to integrate JavaScript (or TypeScript) into a Ruby on Rails application there’s a lot of tradeoffs and options to consider. On one side of the spectrum you have Ruby on Rails, which — in its classic form — is a backend server environment that sends pre-rendered HTML to the browser. On the opposite side you have client-side JavaScript Frameworks which construct responsive and interactive web applications where HTML is dynamically managed within the browser. Each of these exhibit distinct characteristics in how presentational concerns are handled. I believe the delegation of presentational responsibilities should be an important factor in your analysis and decision.

In order to dig further into this, I should first explain what I mean by presentational concerns. We’re looking at the presentation layer: the parts of the software system involved with the visual representation of information and content. To provide some more shape to this discussion, we can draw up a rough mental model. At the boundaries we have the browser which populates the web page display and the server which provides content and backend services. For inputs, we have content and data that exists on the backend as well as user inputs and requests received via the browser. For output, we’re largely concerned with the graphical interface presented to the user.

In particular, this work needs to be delegated. Some responsibilities the chosen design will need to assign, include the following:

  • processing, translation, and interpretation of user input
  • organizing, formatting, and structuring content and information
  • implementation of the behavior, state, and functionality of the user interface

To summarize, clear decisions should be made to answer the following question: To what degree will presentational and behavioral concerns be delegated to the frontend JavaScript?

To assist with this decision, this article covers several key stages that lie between using minimal to maximal amounts of JavaScript within a Rails application. We’ll also review some strategies afforded by each level of integration. For a deeper look into and comparison of the technologies you can choose from, you can also check out another of our blog posts — How to integrate React with Rails 7.

Delegate Page Behavior and Interactivity

The vanilla Rails approach tends to minimize the usage of frontend code. With technology such as view templating, HTML over the wire, and Rails View Components; developers have tools available to build modular and composable frontend content with little to no JavaScript. When employing approaches like unobtrusive JavaScript, graceful degradation and progressive enhancement — the application can even retain full or partial functionality when JavaScript is disabled in the browser.

In this type of application, HTML is rendered server-side and all the business and domain logic is handled on the backend. The role JavaScript plays here is a classic one: enhancing the page. Frontend code is written sparingly and as-needed to add interactivity, manage state, and add functionality. A Stimulus controller, for example, might enhance a navigational menu with the ability to toggle visibility.

When the architectural requirements of an application call for speed and responsiveness, it’s possible to avoid a potentially heavy-handed frontend JavaScript approach by following the Hotwire approach with Turbo and Stimulus. As a personal fan of React (and other frontend frameworks), it’s refreshing to keep the frontend lightweight and simple. With a simpler frontend, your development resources can be concentrated on the backend business logic and domain concerns. When the tradeoffs and situation supporting it, Hotwire is a powerful tool that can set you up for success.

Delegate Specific Presentational Components

This level of integration is similar to the classic Rails approach above. Rails handles most of the presentational concerns with regard to the HTML content. The key difference is that specific regions of the page are replaced with client-side rendered content. A JavaScript component might render a fancy interactive chart or communicate with a vendor API to render a content feed. This is commonly referred to as Islands Architecture1. It’s a hybrid approach that combines the speed of static content with the responsiveness and interactivity of dynamic content.

Approaches to implementation may include:

  • Wrapping JS components in Rails View Components
  • Loading JavaScript components with superglue or react_on_rails
  • Loading JavaScript components with custom code

Delegating the responsibility of specific components to JavaScript might be a good option when your site is largely comprised of low or non-reactive static content, and requires only a few areas of the page be more dynamic and responsive. This approach affords a lot of flexibility in choosing the appropriate technology with which to implement frontend content.

This flexibility, however, comes at a cost. There’s a significant increase in complexity, both cognitively and with the implementation. You have to maintain and track presentational components across two (or more) technologies.

Furthermore, a potential trap is allowing divergence and fragmentation slowly erode and muddy the distinction between what content is status versus dynamic. When it’s unclear how content should be implemented, a developer will need to jump between the JavaScript components and ERB templates/partials to determine where a piece of content lives. As a result, it’s important to have a clear delineation over how various types of content are implemented to avoid this confusion.

I’d recommend this approach when it is expected that an application will only require a minimal number of highly-reactive and dynamic sections and that such content would clearly benefit from the being implemented as a JavaScript component. Implementation would otherwise focus on retaining the benefits of statically rendered and cacheable content as much as possible.

Delegate Page Rendering

The next level of integration delegates the rendering of the entire page to a frontend JavaScript framework. The key features here are that each rendered Rails view becomes a thin wrapper which loads a “Page” component (such as a dashboard filled with data tables and charts). From there, the JavaScript framework takes over and becomes responsible for rendering the page content. The Rails application may also implement additional API routes.

This level of integration can be further subdivided by deciding whether you’d like to employ a technology that supports Unobtrusive JavaScript (UJS)2. Superglue employs React, Redux, and UJS to build rich, dynamic, responsive pages without having to write separate API routes. React on Rails, on the other hand, omits UJS and focuses on handling the integration concerns of using React in a Rails application. Data can be attained through API calls or embedded into the page at load time to be utilized during the initial render or hydration.

Regardless of which technology is chosen, delegating the responsibility of rendering pages to the frontend can be useful as a transitionary strategy for an incremental migration. When migrating to a new React frontend for example, you’re afforded the ability to replace legacy pages one at a time rather than migrate the entire frontend all at once. Rails continues to handle the routing concerns and responds to requests with actions implemented inside ActionControllers.

One downside to this approach is that it can occasionally get a bit verbose and generate more files. Superglue, however, mitigates this by defaulting to using a single default HTML template for every controller action3. Other approaches tend to require an ERB template and a root level JavaScript component for each view. The JavaScript component encapsulates the entirety of the page content. The ERB template serves as a shim to bootstrap the loading of a page component.

Delegate Routing

The next level of separation is to additionally delegate all routing responsibilities to the JavaScript frontend framework. With this strategy, the Rails application serves a single HTML page that loads an entire Single Page Application (SPA). Beyond this, the Rails application focuses on providing API routes and handling all backend responsibilities. The JavaScript frontend assumes not only the responsibility of rendering page content, but also the routing between pages. A React app, for example, could handle routing concerns with react-router and then be able to implement fancy page transitions or retain state between page transition.

A key benefit here is that you can employ an SPA without having to set up and maintain a separate Node-based server. You do, however, lose the scalability that separate servers would afford.

Delegate all Presentation Layer Concerns

The final level of integration maximizes the separation between Ruby and JavaScript. In this scenario, the Ruby on Rails application operates in API mode and only handles backend application concerns. The JavaScript frontend is implemented as a separate service provided by its own server (typically Node-based). This could, for example, be a Next.js application that integrates with the Rails API routes.

The strategy here is to allow the JavaScript Framework to operate independent and assume all responsibility for the frontend application. This can support architectural characteristics such as scalability and deployability. The approach does increase the complexity of the overall system as you’re developing two separate services. It can also lead to increased network activity as the frontend application must communicate with the backend application.

This approach would work well in a system that exhibits more than one distinct set of architectural characteristics. It can support a Backends for Frontends architecture4 to implement separate frontend interfaces for different clients or devices (mobile and desktop for example). It can support a Micro Frontend architectural5 approach to break apart a single frontend into independent application areas. Or it could also be employed as a transitionary stage while moving between a monolith and distributed architecture.

Summary

As you may now recognize, the level of responsibility that you delegate to be handled by JavaScript plays a key role in building a strategy for implementing your application. Each level of integration uniquely handles presentational concerns. When you next decide on a strategy for your frontend, I hope that being aware of these options will equip you to better weigh the tradeoffs between each strategy.


  1. Islands Architecture is an approach where pages are mostly static content rendered server-side. The page, however, contains placeholder elements for specific areas of content to be dynamically replaced in the browser with JavaScript components. For more on this pattern see https://www.patterns.dev/vanilla/islands-architecture/; https://jasonformat.com/islands-architecture/; and https://docs.astro.build/en/concepts/islands/ 

  2. UJS is a technique to attach JavaScript to elements in the DOM. To learn more about UJS, see the Rails guides here and here 

  3. Read more about this Superglue feature here 

  4. A Backends for Frontends Architecture decouples the backend from frontend implementations. See https://learn.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends

  5. A Micro Frontend Architecture decouples distinct areas of an application into separate frontends that can be independently maintained. For more information, see https://micro-frontends.org/ and https://martinfowler.com/articles/micro-frontends.html

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.