Rung 1 — the metaphor

Have your cake and eat it too.

Real React, right in your Rails views — like a partial. Your Ruby data flows straight in. No API, no second repo, no Node in production.

app/views/posts/show.html.erb
<h1><%= @post.title %></h1>

<LikeButton postId={@post.id} />
live — no reload

My First Post

<h1> from ERB · React button, hydrated

That @post.id? It's your Rails data — straight into a React prop. No serializer, no JSON layer to write.

Rung 2 — the data

Your @post — straight into React.

No JSON endpoint. No serializer to hand-write. Just declare what's safe to send.

app/models/post.rb
class Post < ApplicationRecord
  include Ruact::Serializable
  ruact_props :id, :title, :body
end
app/views/posts/show.html.erb
<PostCard post={@post} />

↓ the browser receives only
  { id, title, body }
  — nothing else.
An allowlist, not a serializer. Pass the whole @post and only id, title, body cross the wire — password_digest, tokens and internal flags never leave your server. Safe by construction, loud when you typo.

The alternative? “Render JSON instead of HTML now” — a serializer layer you write and keep in sync. Here, there isn't one. You allowlist; ruact does the rest.

The central wedge

Your database is the state.

No client-side store to keep in sync. When data changes, the server re-renders — you never play “which is right, the server or the client?”

The trap — SPA + API

post.likesout of sync · Δ2

local state44

⇅  sync by hand

client cache43

⇅  sync by hand

server / database42

“Three levels of truth — which one is right?”

ruact

post.likes 

Rails · ActiveRecord42

│  renders ▼

React42

└▶  mutate → revalidate → re-render from source

One home. No drift.

await createPost(formData);   // mutation + refresh from Rails, in one await

No cache to invalidate. No useEffect to re-run. The page shows what Rails knows — because that's the only place it's stored.

Client state still exists — for what it's for: ephemeral UI, a toggle, a hover. What you never do is keep a second copy of your data in sync. That's the drift ruact removes.

The deploy story

Real React. Your prod stack is still just Rails.

The React tree is built on the server, in Ruby — no Node sidecar. In production, nothing Node runs.

app/javascript/components/LikeButton.tsx
"use client"
import { useState } from "react"

export function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false)
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? "❤️ Liked" : "🤍 Like"}
    </button>
  )
}
Procfile — production
web: bundle exec puma

↑ one process.
  no node, ever.

That's real React 19 — the actual library, the actual ecosystem. Not a DSL, not a subset. Bundled once by Vite at deploy (Node at build time, like any JS app); at runtime, only Rails.

Built to be driven — by you, and by your agent.

The same one-repo, one-router simplicity that keeps you in flow is what a coding agent needs to get it right the first time.

See the agent setup →
<PostCard postID={@post.id} />
   └─ unknown prop "postID"did you mean "postId"?

A typo names its own fix — not a stack trace. Your agent reads that and corrects itself. So do you. ruact:install also drops an AGENTS.md and llms.txt, and every diagnostic speaks --json.

Rung 3 — the real app

See it work. Then run it yourself.

A real Rails app. A React datatable. Live search over your data — no API, no reload.

That search? A ruact server query — your Ruby data, straight in. No JSON endpoint, no client cache.

Try it in your own app.

From rails new to a full CRUD with a searchable React table — in a few minutes.

rails new myapp --skip-javascript && cd myapp
bundle add ruact
rails generate ruact:install
rails generate ruact:scaffold Post title:string body:text
rails db:migrate
bin/dev

No sign-up. No account. No Node server to provision. bundle remove ruact and it's gone.