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
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
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
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
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)
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
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
Lets route our controller action to path:
config/routes.rb
Rails.application.routes.draw do
resources :users, only: [:index]
end
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:
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'
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
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
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
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
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>
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
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
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}}
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)
}
})
}
})
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'
app/javascript/application.js
import "components/index.jsx"
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
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
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
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
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
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
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
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
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",
]
}
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/*"]
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
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)
Really cool! I had to add
ts
into["js", "jsx"]
for import map monkey patch to make it work. Thanks!Wow. Really cool man. I was looking for this solution for some time.