Configuring Ruby on Rails to Support React + TypeScript with Webpacker

Kevin Firko
Published:

This guide covers how to configure a new Ruby on Rails (RoR) v6 application to support a React front-end written in TypeScript using Webpacker.

Update: Rails 7 has been released and Webpacker has been retired as the official solution for bundling JavaScript and TypeScript. Check out the latest Rails Guide.

Code from this post is available on GitHub.

We’ll follow a common approach to combining Rails and React to ensure that deployments are supported by leading “white glove” hosting platforms like Heroku that are widely used in the Rails community.

The adoption of React + TypeScript can help modernize legacy monolithic RoR apps and open a wider talent pool of developers.

A key advantage of React is that it is relatively straightforward to introduce it into legacy apps in phases, similar to how Facebook (Meta) first rolled it out when they created the library as an internal solution to build complex UI’s.

Step-by-Step Guide

Prerequisites

Ensure that your workstation is ready with:

  • ruby and node
  • yarn package manager (RoR’s preferred package manager for JavaScript)

Install rails in your ruby environment if you haven’t already done so:

gem install rails -v 6.1

In this guide we’ll use webpacker, a toolset that brings webpack v5+ to modern Rails projects.

Run the following command to scaffold a new rails project with support for React, replacing rails-react with your project name:

rails new rails-react --webpack=react

Add support for TypeScript:

bundle exec rails webpacker:install:typescript

The command adds and configures dependencies for transpiling TypeScript to JavaScript using Babel via the @babel/preset-typescript package.

Docs: Webpacker docs for TypeScript.

Prettier + editorconfig

It is a good idea to add a configuration for prettier along with an .editorconfig file to help improve code consistency and code quality.

VSCode users can add support for the EditorConfig format by installing the EditorConfig Extension.

Here’s an example .editorconfig that can be added to the project root:

root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
max_line_length = off
trim_trailing_whitespace = false

Here’s an example .prettierrc config file for Prettier with opinionated settings:

{
  "parser": "typescript",
  "singleQuote": true,
  "trailingComma": "all",
  "semi": false,
  "bracketSpacing": true,
  "tabWidth": 2,
  "useTabs": false,
  "printWidth": 120
}

Prettier is a powerful tool with multiple config formats, a plugin ecosystem, and more. Check the prettier docs to learn more about how to customize it further.

Type Checking

Type checking is important to actually reap any benefits from using TypeScript. We want the TypeScript compiler to fail the build if it detects any type errors.

At the time of writing type checking must be manually added to Webpack’s compilation process. Start by adding the following plugin:

yarn add fork-ts-checker-webpack-plugin --dev

The plugin can then be added to the development environment specified in config/webpack/development.js:

process.env.NODE_ENV = process.env.NODE_ENV || 'development'
const environment = require('./environment')

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const path = require('path')

environment.plugins.append(
  'ForkTsCheckerWebpackPlugin',
  new ForkTsCheckerWebpackPlugin({
    typescript: {
      configFile: path.resolve(__dirname, '../../tsconfig.json'),
    },
    // non-async type checking will block compilation on error
    async: false,
  }),
)

module.exports = environment.toWebpackConfig()

TypeScript eliminates the need for the prop-types package that’s often used with React written in JavaScript. To remove the package:

yarn remove prop-types

Rename the boilerplate app/javascript/packs/hello_react.jsx to app/javascript/packs/hello_react.tsx.

Refactor the hello_react.tsx file from JS to TS as follows:

// Run this example by adding <%= javascript_pack_tag 'hello_react' %> to the head of your layout file

import React from 'react'
import ReactDOM from 'react-dom'
import App from '../components/App'

const Hello: React.FC<{
  name: string
}> = (props) => <div>Hello {props.name}!</div>

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(<Hello name="React" />, document.body.appendChild(document.createElement('div')))
})

Your code editor might indicate that the some of the syntax in the above code block isn’t valid.

TypeScript’s behaviour is controlled by its config file tsconfig.json.

The following example tsconfig.json incorporates several improvements vs. the boilerplate. You can cherry-pick individual property changes that you agree with or replace your entire tsconfig.json with the example:

{
  "compilerOptions": {
    "strict": true,
    "alwaysStrict": true,
    "strictNullChecks": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "allowJs": false,
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "lib": ["es6", "dom"],
    "module": "es6",
    "moduleResolution": "node",
    "sourceMap": true,
    "target": "es5",
    "jsx": "react",
    "noEmit": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["**/*.spec.ts", "node_modules", "vendor", "public"],
  "compileOnSave": false
}

Some notable tweaks vs. the boilerplate include:

  • Enabling strict mode. TypeScript isn’t TypeScript without strict mode and without it the compiler will allow preventable bugs that it is capable of detecting with more rigorous analysis. Many popular libraries actually depend on strict mode being enabled to produce correct type information.
  • The import statements in the boilerplate hello_react.tsx require "allowSyntheticDefaultImports": true to work with TypeScript. This setting is activated by specifying the "esModuleInterop": true option which is more broad.
  • A number of options (beginning with strict* and no*) are added to improve code standards + quality, and to help eliminate certain classes of bugs.

Add a Controller with an Index to serve the React SPA

Create a controller that will serve an index page containing the React app:

rails g controller pages index

This will generate app/controllers/pages_controller.rb and scaffold an index action.

Set the root in /config/routes.rb to the new index page:

Rails.application.routes.draw do
  root 'pages#index'
end

Start the development server:

# development server
rails s

Visit the default development URL at http://localhost:3000.

The boilerplate content consists of a heading and a paragraph tag (or at least it did in my case with the particular versions that I was running). This content is found in the app/views/pages/index.html.erb file.

Delete the contents of this file (not the file itself) and save the file.

Add the following line to app/views/layouts/application.html.erb before the closing head tag:

<%= javascript_pack_tag 'hello_react' %>

The JavaScript pack tag rigs up the hello_react script that contains the entry point to the React app.

Start coding in React

For a conventional foundation of a React SPA (Single Page App), create the directory app/javascript/components and add App.tsx:

import React, { useState } from 'react'

const App: React.FC = () => {
  return (
    <div>
      <h1>React App UI</h1>
    </div>
  )
}

export default App

Then revise app/javascript/packs/hello_react.tsx to import and render the App component:

import React from 'react'
import ReactDOM from 'react-dom'

import App from '../components/App'

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(<App />, document.body.appendChild(document.createElement('div')))
})

References