Skip to main content

React on Rails Tutorial

See also Examples and migration references for maintained demo apps, migration references, and current React on Rails Pro + RSC demos.

This tutorial starts from the Quick Start app and builds a small TypeScript component using the modern React on Rails workflow:

  • TypeScript function components and React Hooks
  • Auto-bundling from files in ror_components
  • bin/dev with HMR during development
  • Optional server rendering with prerender: true

Redux is still supported, but it is no longer the main path for a first React on Rails app. See Appendix: Redux Integration when you need a shared client store or you are maintaining an existing Redux setup.

Table of Contents

Prerequisites

Use current, maintained versions for new apps:

  • Ruby 3.3+
  • Rails 7+
  • Node.js 18+ and your preferred package manager
  • Foreman or Overmind for bin/dev

React on Rails is published as both a Ruby gem and an npm package. For exact current versions, check:

Start From The Quick-Start App

If you already completed the Quick Start, keep using that app. Otherwise, create a Rails app and run the TypeScript installer:

rails new test-react-on-rails --skip-javascript
cd test-react-on-rails

bundle add react_on_rails --strict
bin/rails generate react_on_rails:install --typescript

The installer sets up Shakapacker, React, TypeScript, the React on Rails initializer, a sample controller, and a bin/dev process file. Fresh installs use Rspack by default when the installed Shakapacker version supports it. To force Webpack instead, pass --no-rspack.

tip

Commit or stash your app before running generators. The diff is much easier to review when generated changes are isolated.

There is no react_on_rails:component generator. For new components, create the files manually under the configured ror_components directory and let auto-bundling discover them.

Understand The Generated Files

The TypeScript installer creates a structure like this:

app/javascript/
└── src/
└── HelloWorld/
└── ror_components/
├── HelloWorld.client.tsx
├── HelloWorld.module.css
└── HelloWorld.server.tsx

The important pieces are:

  • config/initializers/react_on_rails.rb configures React on Rails.
  • config/shakapacker.yml configures the bundler and enables nested entries for generated packs.
  • app/javascript/src/**/ror_components/* contains components that React on Rails can auto-register.
  • Rails views call react_component to render those components.

Auto-bundling means you do not manually create a pack for every component and you do not manually call ReactOnRails.register for the basic component case. React on Rails generates the per-component bundles and loads the right bundle when the view helper asks for it.

Create A TypeScript Counter Component

Create a new component directory:

mkdir -p app/javascript/src/Counter/ror_components

Add app/javascript/src/Counter/ror_components/Counter.client.tsx:

import React, { useState } from 'react';

type CounterProps = {
initialCount?: number;
label?: string;
};

export default function Counter({ initialCount = 0, label = 'Counter' }: CounterProps) {
const [count, setCount] = useState(initialCount);

return (
<section>
<h2>{label}</h2>
<p>
Count: <strong>{count}</strong>
</p>
<button type="button" onClick={() => setCount((current) => current + 1)}>
Increment
</button>
<button type="button" onClick={() => setCount(initialCount)}>
Reset
</button>
</section>
);
}

The file name controls the component name. Counter.client.tsx is rendered from Rails as "Counter". The .client suffix tells auto-bundling this is the browser entry point.

Render The Component From Rails

Add a controller action if you do not already have one:

bin/rails generate controller Dashboard show

In app/controllers/dashboard_controller.rb, set props for React. The Quick Start installer creates react_on_rails_default, which includes the generated bundle placeholders. If that layout is missing, use the fallback note below before copying this controller:

class DashboardController < ApplicationController
layout "react_on_rails_default"

def show
@counter_props = {
# React props use camelCase; react_component serializes this hash to JSON.
initialCount: 3,
label: "Orders ready"
}
end
end

In app/views/dashboard/show.html.erb, render the component:

<h1>Dashboard</h1>

<%= react_component("Counter", props: @counter_props, auto_load_bundle: true) %>

If your app sets config.auto_load_bundle = true in config/initializers/react_on_rails.rb, you can omit auto_load_bundle: true from individual helper calls:

<%= react_component("Counter", props: @counter_props) %>

The generated react_on_rails_default layout includes the Shakapacker tags that auto-bundling needs. If you render from your application's default layout instead, add the same argless pack-tag calls there:

<%= stylesheet_pack_tag %>
<%= javascript_pack_tag %>

With no pack name, Shakapacker renders every bundle accumulated by append_javascript_pack_tag and append_stylesheet_pack_tag, which lets auto-bundling load per-component packs without hardcoding generated pack names in the layout.

If app/views/layouts/react_on_rails_default.html.erb is not present (for example, you added React on Rails manually to an existing app without running the installer), remove the layout line from the controller and add the pack-tag placeholders to your application.html.erb or whichever layout renders this view.

Run The App With HMR

Start Rails and the bundler dev server together:

./bin/dev

Visit the route for your controller, such as http://localhost:3000/dashboard/show. Edit Counter.client.tsx, save, and the page should update through HMR.

Use static bundling when you want to test the production-style compiled assets locally:

./bin/dev static

Pass Props From Rails

props: accepts a Ruby hash or a JSON string. Prefer hashes in normal Rails views so the code stays readable:

<%= react_component(
"Counter",
props: {
initialCount: current_user.notifications.unread.count,
label: "Unread notifications"
},
auto_load_bundle: true
) %>

Keep props serializable. Pass IDs, strings, numbers, booleans, arrays, and hashes; fetch richer client-side data through your usual Rails JSON endpoints or GraphQL layer.

Optional: Turn On Server Rendering

For a component that can render without browser-only APIs, add a server entry before you enable prerendering. Keep both files in the same ror_components directory:

app/javascript/src/Counter/ror_components/
├── Counter.client.tsx
└── Counter.server.tsx

The server file can re-export the same component when no special server behavior is needed:

export { default } from './Counter.client';

The generated TypeScript config uses bundler module resolution, so the extensionless re-export matches the installer defaults. If your app uses a stricter custom Node ESM TypeScript setup, use the relative import extension style required by that config.

Auto-bundling discovers Counter.server.tsx and includes it in the generated server bundle. You do not need to change config.server_bundle_js_file when the generated server-bundle.js entrypoint is already in place.

Then add prerender: true in the Rails view:

<%= react_component("Counter", props: @counter_props, prerender: true, auto_load_bundle: true) %>
note

prerender: true needs a server bundle. For a first local SSR check, use ./bin/dev static or run a production precompile. If you want the dev server to serve the prerender bundle, follow the HMR guide and set config.same_bundle_for_client_and_server = true for that mode.

Server rendering runs in Node or ExecJS, which has no browser globals (window, document, localStorage). Guard browser-only access inside a useEffect hook or a conditional on typeof window !== 'undefined'.

For deeper SSR guidance, see Client vs. Server Rendering and React Server Rendering.

Production Build And Deployment

Before deploying, run the production asset pipeline locally once:

RAILS_ENV=production NODE_ENV=production bin/rails assets:precompile

After verifying the production build locally, remove compiled assets before returning to development:

bin/rails assets:clobber

For deployment details, see:

Troubleshooting

The Component Is Not Found

Check that the component lives under a directory matching config.components_subdirectory, which is usually ror_components:

app/javascript/src/Counter/ror_components/Counter.client.tsx

Then make sure the view uses the component name without the .client or .tsx suffix:

<%= react_component("Counter", props: @counter_props, auto_load_bundle: true) %>

The Bundle Is Not Loaded

Use auto_load_bundle: true on the helper call or set it globally:

# config/initializers/react_on_rails.rb
config.auto_load_bundle = true

Also confirm that nested_entries: true remains enabled in config/shakapacker.yml.

HMR Does Not Update The Page

Run ./bin/dev, not only bin/rails server. The dev command starts both Rails and the bundler dev server.

Server Rendering Fails

Temporarily set prerender: false to confirm the browser render works, then remove browser-only APIs from the server render path. You can also enable trace: true on react_component while debugging:

<%= react_component("Counter", props: @counter_props, prerender: true, auto_load_bundle: true, trace: true) %>

Appendix: Redux Integration

Use Redux when your app already has Redux conventions, needs a shared client store across many React islands, or benefits from Redux middleware and DevTools. For local UI state, React Hooks are usually simpler.

To generate the Redux example for a new app, run the installer with both TypeScript and Redux:

bin/rails generate react_on_rails:install --typescript --redux

The Redux installer creates a larger structure with actions, reducers, store setup, presentational components, containers, and auto-registered entry points under ror_components.

When rendering a Redux-backed component, the Rails side still uses the same view helper style:

<%= react_component("HelloWorldApp", props: @hello_world_props, auto_load_bundle: true) %>

The component is named HelloWorldApp because that is what the Redux installer generates. Adjust the component name and props key to match your app's controller setup.

For manually wired stores or advanced store sharing, use the redux_store helper and the Redux integration guide.

What's Next?