Ruby on Rails React Integration Guide 2025 | Complete Setup & Best Practices

React in Ruby on Rails Cheat Sheet

Overview

This guide covers the low-level mechanics of integrating React components into Rails views, passing data between Rails and React, and the basic architectural patterns.

Basic Setup (Quick Reference)

Installation

# Add react-rails gem
bundle add react-rails

# Install React via Webpack/Shakapacker
rails webpacker:install:react
# OR for newer Rails with importmap
rails importmap:install
rails turbo:install

Creating React Components

Component File Location

app/javascript/components/MyComponent.jsx

Basic Component Structure

jsx// app/javascript/components/HelloWorld.jsx
import React from 'react';

const HelloWorld = ({ name, message }) => {
  return (
    <div className="hello-world">
      <h1>Hello, {name}!</h1>
      <p>{message}</p>
    </div>
  );
};

export default HelloWorld;

Component with State

jsx// app/javascript/components/Counter.jsx
import React, { useState } from 'react';

const Counter = ({ initialCount = 0 }) => {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
};

export default Counter;

Registering Components

With react-rails gem

// app/javascript/components/index.js
import HelloWorld from './HelloWorld';
import Counter from './Counter';
import UserProfile from './UserProfile';

// Register components for use in Rails views
const components = {
  HelloWorld,
  Counter,
  UserProfile
};

export default components;

Entry Point Setup

// app/javascript/application.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import components from './components';

// Auto-mount components on page load
document.addEventListener('DOMContentLoaded', () => {
  const mountPoints = document.querySelectorAll('[data-react-component]');
  
  mountPoints.forEach((mountPoint) => {
    const componentName = mountPoint.dataset.reactComponent;
    const props = JSON.parse(mountPoint.dataset.reactProps || '{}');
    const Component = components[componentName];
    
    if (Component) {
      const root = createRoot(mountPoint);
      root.render(<Component {...props} />);
    }
  });
});

Using Components in Rails Views

Method 1: Using react-rails Helper

<!-- app/views/pages/index.html.erb -->

<!-- Simple component with no props -->
<%= react_component("HelloWorld") %>

<!-- Component with props -->
<%= react_component("HelloWorld", { 
  name: "John", 
  message: "Welcome to Rails with React!" 
}) %>

<!-- Component with data from controller -->
<%= react_component("UserProfile", { 
  user: @user.as_json,
  editable: current_user == @user
}) %>

<!-- Component with nested data -->
<%= react_component("Dashboard", {
  stats: {
    users: @users_count,
    posts: @posts_count,
    comments: @comments_count
  },
  recentActivity: @recent_activities.as_json
}) %>

Method 2: Manual DIV with data attributes

<!-- app/views/pages/show.html.erb -->
<div 
  data-react-component="Counter" 
  data-react-props='<%= { initialCount: 10 }.to_json %>'
></div>

<!-- With controller data -->
<div 
  data-react-component="UserProfile" 
  data-react-props='<%= @user.to_json %>'
></div>

Method 3: Inline JSON Script (for complex data)

<!-- app/views/posts/show.html.erb -->
<div id="post-detail-root"></div>

<script type="application/json" id="post-data">
  <%= raw @post.to_json(include: [:author, :comments]) %>
</script>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    const postData = JSON.parse(
      document.getElementById('post-data').textContent
    );
    // Mount component with data
    // (Your JS bundle handles this)
  });
</script>

Controller Setup

Basic Controller Pattern

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    # Fetch data for React component
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .limit(20)
    
    # Optional: prepare data structure for React
    @posts_data = @posts.map do |post|
      {
        id: post.id,
        title: post.title,
        excerpt: post.excerpt,
        author: {
          name: post.author.name,
          avatar_url: post.author.avatar_url
        },
        created_at: post.created_at.iso8601
      }
    end
  end

  def show
    @post = Post.includes(:author, comments: :user).find(params[:id])
    
    # Serialize for React consumption
    @post_json = @post.as_json(
      include: {
        author: { only: [:id, :name, :avatar_url] },
        comments: {
          include: {
            user: { only: [:id, :name] }
          }
        }
      }
    )
  end
end

Controller with API Endpoint (Hybrid Approach)

# app/controllers/api/posts_controller.rb
module Api
  class PostsController < ApplicationController
    # For React component that fetches data after mount
    def index
      @posts = Post.includes(:author).all
      
      respond_to do |format|
        format.html # Renders normal view
        format.json { render json: @posts }
      end
    end

    def show
      @post = Post.find(params[:id])
      render json: @post.to_json(include: [:author, :comments])
    end
  end
end

Model Serialization

Simple Serialization

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts

  # Define what gets sent to React
  def as_json(options = {})
    super(options.merge(
      only: [:id, :name, :email, :avatar_url],
      methods: [:full_name]
    ))
  end

  def full_name
    "#{first_name} #{last_name}"
  end
end

Using Serializers (Better for complex data)

# app/serializers/user_serializer.rb
class UserSerializer
  def initialize(user)
    @user = user
  end

  def as_json
    {
      id: @user.id,
      name: @user.name,
      email: @user.email,
      avatar_url: @user.avatar_url,
      posts_count: @user.posts.count,
      joined_at: @user.created_at.iso8601
    }
  end
end

# In controller:
@user_data = UserSerializer.new(@user).as_json

Passing Data Patterns

Pattern 1: Server-Rendered Props (Initial Load)

<!-- View -->
<%= react_component("TodoList", {
  todos: @todos.as_json,
  user_id: current_user.id,
  can_edit: policy(@todos).edit?
}) %>
// Component receives all data upfront
const TodoList = ({ todos, userId, canEdit }) => {
  const [items, setItems] = useState(todos);
  // Component works with initial data
};

Pattern 2: Fetch After Mount (SPA-like)

<!-- View passes minimal props -->
<%= react_component("TodoList", {
  user_id: current_user.id,
  api_endpoint: api_todos_path
}) %>
// Component fetches its own data
const TodoList = ({ userId, apiEndpoint }) => {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(apiEndpoint)
      .then(res => res.json())
      .then(data => {
        setTodos(data);
        setLoading(false);
      });
  }, [apiEndpoint]);

  if (loading) return <div>Loading...</div>;
  return <div>{/* Render todos */}</div>;
};

Pattern 3: Hybrid (Initial + Updates)

<%= react_component("PostDetail", {
  post: @post.as_json,
  comments_url: post_comments_path(@post)
}) %>
const PostDetail = ({ post: initialPost, commentsUrl }) => {
  const [post, setPost] = useState(initialPost);
  const [comments, setComments] = useState(initialPost.comments);

  const loadMoreComments = () => {
    fetch(`${commentsUrl}?offset=${comments.length}`)
      .then(res => res.json())
      .then(newComments => setComments([...comments, ...newComments]));
  };
};

Common Patterns & Gotchas

Environment Variables

<!-- Pass Rails env vars to React -->
<%= react_component("App", {
  api_key: ENV['API_KEY'],
  environment: Rails.env,
  base_url: root_url
}) %>

Date/Time Handling

# Controller - always use ISO8601 for dates
@event_data = {
  starts_at: @event.starts_at.iso8601,
  ends_at: @event.ends_at.iso8601
}
// Component
const EventDetail = ({ startsAt, endsAt }) => {
  const startDate = new Date(startsAt);
  return <div>{startDate.toLocaleDateString()}</div>;
};

Turbo/Turbolinks Compatibility

// If using Turbo, listen for turbo:load instead of DOMContentLoaded
document.addEventListener('turbo:load', () => {
  // Mount React components
});

// Cleanup on navigation
document.addEventListener('turbo:before-render', () => {
  // Unmount React components if needed
});

Asset Pipeline URLs

<%= react_component("ImageGallery", {
  images: @images.map { |img| 
    {
      id: img.id,
      url: image_url(img.file),
      thumbnail_url: image_url(img.thumbnail)
    }
  }
}) %>

File Structure Example

app/
├── controllers/
│   ├── posts_controller.rb          # Traditional Rails controller
│   └── api/
│       └── posts_controller.rb      # API endpoints for React
├── models/
│   └── post.rb                      # Model with as_json customization
├── views/
│   ├── posts/
│   │   ├── index.html.erb          # Contains react_component calls
│   │   └── show.html.erb
│   └── layouts/
│       └── application.html.erb     # Includes React bundle
├── javascript/
│   ├── application.js               # Entry point, mounts components
│   ├── components/
│   │   ├── index.js                # Exports all components
│   │   ├── PostList.jsx
│   │   ├── PostDetail.jsx
│   │   └── PostForm.jsx
│   └── utils/
│       ├── csrf.js                  # CSRF helpers
│       └── api.js                   # API wrapper functions
└── serializers/                     # Optional
    └── post_serializer.rb

Summary

  • Data flows one direction: Controller → View → React Props
  • Serialize dates as ISO8601 strings for JavaScript compatibility
  • Use .as_json or serializers to control what data goes to React
  • Component registration is required before using in views
  • Initial data vs fetch: Decide if component gets data via props or fetches it
  • Models don't change: React doesn't affect your ActiveRecord models
  • Controllers prepare data: Controllers shape data for React consumption