DEV Community

George Gavrilchik
George Gavrilchik

Posted on • Updated on

Ruby on Rails with React on Typescript using importmaps

With React and Rails, you can build a great application that has a fast, responsive, and dynamic user interface, as well as a robust and scalable back-end. Typescript adds strong typing, better tooling, and error checking to your code, making it more reliable and maintainable, especially when working with complex UIs in React. In the past, webpacker was commonly used to bring these components together, but now there is a simpler way to accomplish this, and in this article, I will demonstrate how to do so.

In this tutorial, we will create a Rails application that displays a table of records from the database using React written in Typescript. While this approach is more complex than a traditional 'hello world' example, it allows us to explore aspects that are only visible when dealing with real-world data.

Setting up the app

Assuming that you're interested in this topic, I suspect that you already know how to create a new Rails application. Nevertheless, for the sake of completeness, I'll provide the console commands and the code that I used. So, let's create the app. These are the console commands:

rails new rails-react-typescript --database=postgresql
rails db:create
Enter fullscreen mode Exit fullscreen mode

Add some gems go Gemfile in the app root folder:

gem "sassc-rails"
gem "bulma-rails"
gem "haml-rails"

group :development, :test do
  gem "factory_bot_rails"
  gem "faker"
end

Enter fullscreen mode Exit fullscreen mode

This part is optional, but for the purposes of this tutorial, I used HAML for its readability and Bulma to avoid distracting you with the styles. We'll also use factory_bot_rails and faker to generate some data.

Given that the user model is the most commonly used model in web applications, and the other purpose of this exercise is to create a template for experimentation, let's create a user model and controller. As you may have guessed, the first line simply converts the standard ERB layout to HAML. And yes, these commands should be run in the console:

rails g haml:application_layout convert
rails g model User first_name last_name email
rails g controller Users index
Enter fullscreen mode Exit fullscreen mode

Let's populate our database with some data. We will create a factory that will populate our users with some random names. Be sure that the Faker gem will create a lot of Van Der Lindes, O'Hares, and Sikorsky-Konczenys to remind you that names could consist of not only characters.
Factory: spec/factories/users.rb

FactoryBot.define do
  factory :user do
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    email do 
      Faker::Internet.email(
        name: "#{first_name} #{last_name}",
        separators: %w[+ _ .].sample 
      )
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Let's create one hundred users so that we have more than we are going to display on the screen.
Seed file: db/seeds.rb

FactoryBot.create_list(:user, 100)
Enter fullscreen mode Exit fullscreen mode

Now it is time for a controller and view.
app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.limit(20)
  end
end
Enter fullscreen mode Exit fullscreen mode

app/views/users/index.haml

.columns
  .column
    %h1.title 
      This is Users index page

.columns
  .column
    %table.table
      %thead
        %tr
          %th First Name
          %th Last Name 
          %th Email 
      %tbody
        -@users.each do |user|
          %tr
            %td=user.first_name
            %td=user.last_name
            %td=user.email
Enter fullscreen mode Exit fullscreen mode

Lets route our controller action to path:
config/routes.rb

Rails.application.routes.draw do
  resources :users, only: [:index]
end
Enter fullscreen mode Exit fullscreen mode

Now if you start your server with rails s command and visit http://localhost:3000/users/ in your browser you should see something like this:
Users index page
We will use this picture as a reference for what we will get first with React and then with TypeScripted React.

Adding React

There are various ways to integrate React with a Rails application, but the two most commonly used methods are through Webpacker and Importmap. With Webpacker, JavaScript files are bundled together with all their dependencies, which is convenient since the browser only needs to download one file. However, if you use the same modules across different pages, the entire bundle still needs to be downloaded. On the other hand, the Importmap approach involves adding a mapping object to the head tag of the page that contains links to the modules mapped with shortcuts. Although this approach requires the user to download more files, each file is only downloaded once, which can be beneficial in certain situations. We will be using the second approach.

Let's begin by installing the necessary dependencies. The first gem generates the importmap object, manages caching, and helps with library installations, among other things. I recommend reading the entire readme to become familiar with its capabilities. The second gem will be discussed later, it is used to compile JSX files.
Gemfile

gem 'importmap-rails'
gem 'jass-react-jsx'
Enter fullscreen mode Exit fullscreen mode

By default, the bundle command performs an install action. So, if you simply type bundle, it will run bundle install. The creators of Ruby were known for their love of productivity and dislike of excessive typing.
Console

bundle
./bin/rails importmap:install
Enter fullscreen mode Exit fullscreen mode

Even the creators of importmap use the installation of React as an example in their description, which is relatively easy to do. However, by default, importmap does not download libraries, but instead adds links to their CDN. I don't think this approach is suitable when it comes to the cornerstone of your frontend. But if you read the readme, you'll see that if you add the --download flag, it will download the libraries to the vendor/javascript folder. By default, the production version is used, which is uglified (minified for faster download). However, if you want to gain a better understanding of how React works, it makes sense to add --env development.
Update form 2024: importmap gem now downloads files from cdn into /vendor/javascript folder by default. So you don't need the --download flag. Env is also better not to use, some packages have relative links in their imports when I used it.
Console

 ./bin/importmap pin react-dom
 ./bin/importmap pin react/jsx-runtime
Enter fullscreen mode Exit fullscreen mode

After a successful installation, your config/importmap.rb file should look like the following. React and Scheduler were installed as dependencies of the two libraries you installed.

pin "application", preload: true
pin "react" # @18.2.0
pin "react-dom" # @18.2.0
pin "scheduler" # @0.23.0
pin "react/jsx-runtime", to: "react--jsx-runtime.js" # @18.2.0
Enter fullscreen mode Exit fullscreen mode

You also need to add the javascript_importmap_tags command to your application layout (or to the layout that you plan to use for React. You still have some flexibility here).
app/views/layouts/application.haml

!!!
%html{lang: @locale || 'en'}
  %head
    %title RailsReactTypescript
    =csrf_meta_tags
    =csp_meta_tag
    =stylesheet_link_tag "application", "data-turbo-track": "reload"
    =javascript_importmap_tags
  %body
    .container.pt-6
      =yield
Enter fullscreen mode Exit fullscreen mode

If you have followed all the steps correctly, when you visit http://localhost:3000/users, you should see the following script in the head tag of the page. Note that if you haven't defined a home route and visit http://localhost:3000, Rails will show you the standard page with the Rails logo, and the application.haml won't be used in this case.

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application-3897b39d0f7fe7e947af9b84a1e1304bb30eb1dadb983104797d0a5e26a08736.js",
    "react": "/assets/react-966a3cad9caee3143fc35d19c2d17646673c8be717de9fe5da692795937aef31.js",
    "react-dom": "/assets/react-dom-e1e937ef589506ded59be991d31e5b379dfa35736150987a9be011be8aef8979.js",
    "scheduler": "/assets/scheduler-a9e71960214a0092b75125afa150680aa5e1d69c6c78201b400c5123a783bc7d.js",
    "react/jsx-runtime": "/assets/react--jsx-runtime-50992344115f3199745f0f319a0c41c92b709624ac01ff3c720a857d60a97e39.js"
  }
}</script>
Enter fullscreen mode Exit fullscreen mode

Now, let's create our first component. I'll create a single component that draws the same table we drew with haml earlier. Since I'm going to switch to TypeScript, it doesn't make sense to split it up for now. However, when you create your own component, I recommend including useState or one of the other hooks to make sure that React is working properly.
app/javascript/components/UsersTable.jsx

import React, {useState} from 'react'

const UsersTable = props => {

  const [users, setUsers] = useState(props.users)

  return (
    <table className='table'>
      <thead>
        <tr>
          <th>First Name</th>
          <th>Last Name </th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody>
        {users.map(user => {
          return(
            <tr>
              <td>{user.first_name}</td>
              <td>{user.last_name}</td>
              <td>{user.email}</td>
            </tr>
          )
        })}
      </tbody>
    </table>
  )
}

export default UsersTable
Enter fullscreen mode Exit fullscreen mode

Have you noticed that we used JSX syntax? It is not what the browser understands by default. In Rails, the Sprockets gem is responsible for translating from the languages that developers like to write to the languages that the browser can run. However, it doesn't compile JSX by default. You can learn from the Sprockets fascinating readme on how to befriend it with new file types, but for JSX it is already done by the creator of the jass-react-jsx gem. Therefore, there is no reason to write the code again that is already written and working. It uses Babel, a JavaScript library that converts one JavaScript to another. It requires Node.js to run. I can't imagine a case where you have a Rails app installed but Node.js isn't, but the fact that I can't imagine it doesn't mean that it's impossible. So lets add babel to our app:
Console

 yarn add @babel/core @babel/plugin-transform-react-jsx
Enter fullscreen mode Exit fullscreen mode

You can also add @babel/cli to use a command line interface.

Let's add code that imports React and renders our component when the page is loaded. The jass-react-jsx gem provides an example, but we can extend it for better functionality. If you successfully install React, you'll likely have more than one component, especially if you're using TypeScript. However, there may be pages like a privacy policy page that don't need it at all. To address this, it makes sense to create a function that searches the page for elements with a specific property. If it finds them, it loads React and renders the specified component. Lets start from changing our view:
app/views/users/index.haml

.columns
  .column
    %h1.title This is Users index page

.columns
  .column{data: {behavior: :react, component: "#{asset_path("components/UsersTable.js")}",  props: {users: @users}.to_json}}
Enter fullscreen mode Exit fullscreen mode

Please note that instead of rendering the table as before, we are now rendering an empty div with three data properties. These properties are a trigger for using React, a direct path to the component, and the props. I will discuss the ways of passing props later on, but for now, I would like to explain why I didn't include my component in the importmap. It makes sense to include only those files in the importmap that would be imported by other files. If I was using the approach of splitting the components into smart and dumb, in this example, I would include the dumb components in the importmap and provide a direct path to the smart components. This also enables you to conceal the logic from anyone who is not authorized to view it. Let's now add a code that can search the page for elements with data-behavior="react" and render the component accordingly. It's important to note that the file containing your code should have a .jsx extension, so it can be processed by Babel. You can place the file wherever you want, but I chose to put mine in app/javascript/components/index.jsx.

document.addEventListener('DOMContentLoaded', async () => {

  const reactContainersNodelist = document.querySelectorAll('[data-behavior="react"]')

  if (reactContainersNodelist.length) {

    const React = await import('react')
    const ReactDOM = await import('react-dom')

    const reactContainers = Array.from(reactContainersNodelist).reduce((result, el) => {

      result[el.dataset.component] ||= []
      result[el.dataset.component].push(el)
      return result

    }, {})

    const App = (Component, props) => {

      return (
        <div>
          <React.Suspense fallback={<div>Loading...</div>}>
            <Component {...props}/>
          </React.Suspense>
        </div>
      )

    }

    Object.entries(reactContainers).forEach(([path, nodes]) => {

      const Component = React.lazy(() => import(path))

      for (const node of nodes) {

        ReactDOM.render(App(Component, JSON.parse(node.dataset.props)), node)

      }

    })

  }
})

Enter fullscreen mode Exit fullscreen mode

Let's dive into how this works. After the page is loaded, the code selects all the future React containers, groups them by component, and renders them wrapped in suspense. Suspense is a special component that shows users what is set as the fallback prop while the component is loading. React.lazy loads the component when it's rendered for the first time. The import method accepts a URL as well as an importmap key, so it's not necessary to include it in the importmap. It also makes sense to wrap suspense in an error boundaries component and handle any errors that may occur (at the very least, we should stop showing 'Loading...' as it can be frustrating for the user).
Let's add our file to importmap and include a line in application.js to execute it. Another option is to rename application.js to application.jsx and paste all the code there. The choice is yours.
config/importmap.rb

pin 'components/index.jsx', to: 'components/index.js'
Enter fullscreen mode Exit fullscreen mode

app/javascript/application.js

import "components/index.jsx"
Enter fullscreen mode Exit fullscreen mode

If you have followed all the steps correctly, when you visit http://localhost:3000/users/, you should see the same table we created with Rails view, but this time, it's rendered using React. Or a blank page if you haven't.

Before we move on to TypeScript, there's an important thing to mention regarding importmap gem: it caches the data and only clears the cache if any file with a .js extension changes. JS but not JSX. This means that you have to restart the server every time you make a code change, which is not ideal for development and can be quite annoying. To address this issue, I haven't found a better solution than redefining the importmap cache_sweeper method.
config/initializers/importmap_rails.rb

Importmap::Map.class_eval do

  def cache_sweeper(watches: nil)
    if watches
      @cache_sweeper =
        Rails.application.config.file_watcher.new([], Array(watches).collect { |dir| [ dir.to_s, ["js", "jsx"]] }.to_h) do
          clear_cache
        end
    else
      @cache_sweeper
    end
  end

end
Enter fullscreen mode Exit fullscreen mode

Switching to typescript

The next steps may seem relatively easy compared to what we have already done. We are already using Babel to transform JSX into JS, but we need to make a few more adjustments to transform TSX and TS files. It is also high time to split our components into smart and dumb and use imports inside components to ensure they can be imported during runtime and that TypeScript can perform a type check.

Let's start by adding a plugin to Babel to transform TypeScript and register a new compiler with Sprockets. I have used the same approach as the jass-react-jsx gem, running Babel using the nodo gem. So if you haven't installed jass-react-jsx or if you're starting to read from this chapter, you need to install at least nodo.
console

yarn add @babel/plugin-transform-typescript
Enter fullscreen mode Exit fullscreen mode

config/initializers/sprockets_rails.rb

class TSXCompiler < Nodo::Core

  require babelCore: '@babel/core',
          pluginTransformTypescript: '@babel/plugin-transform-typescript',
          pluginTransformReactJSX: '@babel/plugin-transform-react-jsx'

  class_function def call(input)
    filename = File.basename(input[:filename])
    source = input[:data]
    { data: compile_component(source, filename) }
  end

  function :compile_component, <<~'JS'
    (source, filename) => {
      let code = '';
      nodo.debug(`Compiling component ${filename}`);

      const result = babelCore.transformSync(source,
        { plugins: [
          ["@babel/plugin-transform-react-jsx", { "runtime": "automatic" } ],
          ["@babel/plugin-transform-typescript", { "isTSX": true, "allExtensions": true } ]
        ]
      }
      );

      return result.code;
    }
  JS

end

Sprockets.register_mime_type 'text/tsx', extensions: %w[.tsx .ts], charset: :unicode
Sprockets.register_transformer 'text/tsx', 'application/javascript', TSXCompiler
Enter fullscreen mode Exit fullscreen mode

Lets make a universal table we can reuse later. First of all lets create a method for user model that returns data for the table
app/models/user.rb

  def self.to_table
    {
      headers: {
        first_name: 'First Name',
        last_name: 'Last Name',
        email: 'Email',
      },
      data: all.select(:id, :first_name, :last_name, :email)
    }.to_json
  end

Enter fullscreen mode Exit fullscreen mode

Then 3 presentational components
app/javascript/components/Table.tsx

import React from 'react'

interface TableProps {
  headers: string[]
  children: JSX.Element[]
}

const Table: React.FC<TableProps> = ({ headers, children }) => {

  return (
    <table className='table'>
      <thead>
        <tr>
          {headers.map(header => <th key={`header_${header}`}>{header}</th>)}
        </tr>
      </thead>
      <tbody>
        {children}
      </tbody>
    </table>
  )

}

export default Table
Enter fullscreen mode Exit fullscreen mode

app/javascript/components/TableRow.tsx

import React from 'react'

export interface TableRowProps {
  key: string
  children: JSX.Element[]
}

const TableRow: React.FC<TableRowProps> = ({ key, children }) => {

  return (
    <tr key={key}>
      {children}
    </tr>
  )

}

export default TableRow
Enter fullscreen mode Exit fullscreen mode

app/javascript/components/TableCell.tsx

import React from 'react'

export interface TableCellProps {
  key: string
  data: string | number | boolean
}

const TableCell: React.FC<TableCellProps> = ({ key, data }) => {

  return (
    <td key={key}>
      {data}
    </td>
  )

}

export default TableCell
Enter fullscreen mode Exit fullscreen mode

and a component with logic:
app/javascript/containers/DataTable.tsx

import React, { useState } from 'react'
import Table from 'components/Table'
import TableRow from 'components/TableRow'
import TableCell from 'components/TableCell'

interface ActiveModel {
  id: number
  [key: string]: string | number | boolean
}

interface DataTableProps {
  headers: Record<string, string>
  data: ActiveModel[]
}

const DataTable: React.FC<DataTableProps> = props => {

  const [data, setData] = useState<ActiveModel[]>(props.data)

  const { headers } = props

  return (
    <Table headers={Object.values(headers)}>
      {data.map(item => <TableRow key={`row_${item.id}`}>
        {Object.keys(headers).map(attr => <TableCell key={`cell_${item.id}_${attr}`} data={item[attr]}/>)}
      </TableRow>) }
    </Table>
  )

}

export default DataTable
Enter fullscreen mode Exit fullscreen mode

It's worth noting that we don't need to specify file extensions for imports, nor do we start our paths with './'. TypeScript understands this convention, although on other projects I had to specify paths in the tsconfig.json file. By the way, let's create the tsconfig.json file if you haven't already.
tsconfig.json

{
  "compilerOptions": {
      "module": "system",
      "noImplicitAny": true,
      "removeComments": true,
      "preserveConstEnums": true,
      "strictNullChecks": true,
      "jsx": "react",
      "target": "ES2017"
  },
  "include": [
      "app/javascript/**/*"  ],
  "exclude": [
      "node_modules",
  ]
}
Enter fullscreen mode Exit fullscreen mode

If typescript doesn't see your components just add "paths" key to the "compilerOptions" part and the specify it like this:

"components/*": ["wherever/they/are/stored/*"]
Enter fullscreen mode Exit fullscreen mode

Now for the final step, we'll add our presentational components to the importmap. We need to add them as JS files, so let's add something like this to config/importmap.rb:

Dir['app/javascript/components/**/*'].each do |path|
  path = path.sub('app/javascript/', '')
  key = path.sub(/(\.jsx|\.tsx|\.ts)/, '')
  source = path.sub(/(\.jsx|\.tsx|\.ts)/, '.js')
  pin key, to: source
end
Enter fullscreen mode Exit fullscreen mode

Remember to update the component name in the view and call the method you created for user then you'll be able to see the same table, but this time it's implemented using a statically typed language!

Few words about props as a conclusion

Now that we're able to create components using TypeScript, let's discuss how to pass data to them. There are generally two ways to provide data: render them with the page or make your components request data from the backend when they're mounted. However, neither of these ways is perfect as there's always a risk that data packed into an HTML attribute could be interpreted by the browser as part of the markup. For instance, if you use the common notation to display emails like UserNameuser@email, there's a high chance that the browser will interpret it as a closing tag. Another approach is to request data from server when components is mounted. This way, the components can receive the necessary and there is no risk of data being interpreted as HTML attributes. In my opinion, combining both approaches is a good strategy, providing a minimal set of data that is guaranteed to be safe and requesting the rest from the server. For example, in the case of the users table, we can provide the URL to request data as a prop and request other data from the server.

If you have any other approaches or ideas, please feel free to share them in the comments. I hope this tutorial was helpful to you. If you didn't have a chance to read it and just scrolled down to find a link to the full code, here it is.

Top comments (2)

Collapse
 
bogdan_6 profile image
Bogdan Gusiev

Really cool! I had to add ts into ["js", "jsx"] for import map monkey patch to make it work. Thanks!

Collapse
 
souzanato profile image
Renato de Souza

Wow. Really cool man. I was looking for this solution for some time.