docs / core concepts
ERB as Server Components
How ruact treats your ERB templates as React Server Components, and what that means for props, nesting and rendering.
ruact lets you write your UI in ERB — exactly as you know from Rails — and automatically serializes it as a React element tree. No JSX on the server, no separate component files for server logic.
How it works
Section titled “How it works”When you use a PascalCase tag in an ERB template, ruact treats it as a React element:
<h1>Posts</h1>
<% @posts.each do |post| %> <PostCard post={post} /><% end %>The ERB preprocessor converts <PostCard post={post} /> into a call that creates a React element with the PostCard type and { post: post } as props. The Flight serializer then encodes the entire tree as a wire format payload.
The browser receives this payload and renders it as a live React application — including full interactivity for any "use client" components nested inside.
Tag rules
Section titled “Tag rules”| ERB syntax | React equivalent | Notes |
|---|---|---|
<PostCard /> |
<PostCard /> |
Self-closing, no props |
<PostCard post={@post} /> |
<PostCard post={post} /> |
Ruby expression as prop value |
<PostCard title={"Hello"} /> |
<PostCard title="Hello" /> |
String props use braces too |
<Card>…</Card> |
<Card>{children}</Card> |
The content is the children prop (see Children) |
<Suspense fallback="Loading..."> |
<Suspense fallback={...}> |
fallback is a quoted string |
Prop values are Ruby expressions
Section titled “Prop values are Ruby expressions”Everything inside {...} is evaluated as Ruby:
<PostCard post={@post} canEdit={current_user.admin?} comments={@post.comments.limit(5)}/>Rails helpers, ActiveRecord associations, Ruby methods — all work as expected. The values are serialized to JSON by the Flight serializer before reaching the browser.
Attributes take the three JSX forms. title={@post.title} is a Ruby
expression. title="Hello" is the string "Hello"; a quoted value is never
run as Ruby. A bare disabled is true. Anything else, including ERB inside a
quoted value (title="<%= @t %>"), raises a template error that names the
attribute: write title={@t}. A component tag cannot contain >, even inside
a quoted value; pass such text from Ruby.
Children
Section titled “Children”Content between a component’s tags reaches it as children, rendered on the
server like the rest of the template:
<Card title={@post.title}> <p><%= @post.body %></p> <% @post.tags.each do |tag| %> <Tag name={tag.name} /> <% end %> <%= render "posts/meta", post: @post %></Card>Card is a client component; it receives the <p>, the Tag components and
the partial’s HTML as children and places them wherever it renders
children. The content is ordinary ERB: <%= %> escapes as usual, and loops,
conditionals, partials and other components (the same one included) work
inside. Interactive components inside the children keep their state like any
other.
Every component tag closes: <Badge label={@x} /> has no children, <Card>…</Card>
has them. An opening tag left open, a closing tag with no opening, or content
together with a children={…} prop raises Ruact::ComponentTagError when the
template compiles, naming the file and line. A component that declares a
contract (__ruactContract) lists children among its props or slots to
take them.
Composing a component from its parts. Components that share state through
React context compose in ERB as they do in JSX. A dialog built from shadcn/ui’s
parts (Radix underneath) opens from the trigger, and its content can hold
server-rendered HTML and other components. Each part has to come from a
"use client" file; for shadcn, see
Compose the parts in ERB:
<Dialog> <DialogTrigger asChild> <button>Details</button> </DialogTrigger> <DialogContent> <DialogTitle><%= @product.name %></DialogTitle> <p>Owner: <%= @product.owner.name %></p> <LikeButton productId={@product.id} /> </DialogContent></Dialog>Key the items of a list that holds components. ERB has no key, so React
matches the children of a list by position. When the list changes — a comment
added on top, a row removed — the state of a component inside an item stays
at its old position and shows on the wrong item. Give each item a
data-react-key; ruact makes it the element’s React key:
<ul> <% @comments.each do |comment| %> <li data-react-key="comment-<%= comment.id %>"> <%= comment.body %> <LikeButton commentId={comment.id} /> </li> <% end %></ul>React does not warn about a missing key here, so nothing reminds you.
Refreshing the children after a mutation. A component inside the children
can call a server function and then
revalidate(). The server renders the page again, with the new data in the
children, and React updates it in place, without reloading the document.
Components keep their state, inside the children and out; a component inside
a list item keeps its own if the item is keyed (above).
"use client";import { useState } from "react";import { createComment, revalidate } from "@/.ruact/server-functions";
export function CommentForm({ productId }) { const [body, setBody] = useState(""); const [error, setError] = useState(null);
async function submit(event) { event.preventDefault(); const result = await createComment({ product_id: productId, body }); if (result.ok) { setBody(""); setError(null); await revalidate(); } else { setError(result.errors.body?.[0]); } }
return ( <form onSubmit={submit}> <input value={body} onChange={(event) => setBody(event.target.value)} /> <button>Comment</button> {error && <p>{error}</p>} </form> );}On a failed save the action falls through after ruact_errors, so the call
resolves with ok: false and the errors map instead of throwing. The
server functions page has the details.
class CommentsController < ApplicationController include Ruact::Server
def create comment = Product.find(params[:product_id]).comments.build(body: params[:body]) @ok = comment.save ruact_errors(comment) endendSuspense and streaming
Section titled “Suspense and streaming”<Suspense> — a built-in paired tag — marks a part of the page that can arrive
later:
<Suspense fallback="Loading comments..."> <CommentsList comments={@comments.to_a} /></Suspense>What happens depends on whether the action streams:
- In an action that streams — declared with
ruact_streaming— the page’s shell and every fallback reach the browser first. The content between the tags runs after that, one<Suspense>at a time, in page order, and replaces its fallback when it is ready. On the first load and on a navigation alike. - In an action that does not, the content runs where it is, before anything is sent, and the page arrives whole: the fallback is never seen. In development the log names the line that makes the action stream.
What runs later, and what does not
Section titled “What runs later, and what does not”- Only the content between the tags. What the controller did before the
page rendered is done before the shell is sent: a
find, a relation it loaded. To start a slow query early, callload_asyncin the controller and read the result inside the<Suspense>: the query runs while the shell is sent. That needsconfig.active_record.async_query_executor = :global_thread_pool— without it (a new Rails app’s default)load_asyncruns its query where it is called, andrails ruact:doctorwarns. Inside a transaction, or with the executor’s queue full, Rails runs it in place anyway. - Status, redirects, headers, cookies, the session and the flash belong to
the controller, set before the page renders. The response has started when
a
<Suspense>’s content runs, so a write there never reaches the browser — in development it is an error of that boundary. The same holds for anafter_actiononce the page was sent; one that raises is reported toRails.error. content_for(andprovide) inside a<Suspense>run after the layout was sent, so the layout never shows them: in development it is an error of that boundary. Reading (content_for?,yield :name) works.- The content sees its local variables as they were at the tag — every item of a loop shows its own. It is the variable, not a copy: an object changed below the tag shows the change. Instance variables are read when the content runs.
- The content is its own Ruby scope, and it runs after the rest of the
template: a local variable assigned inside does not exist after
</Suspense>, and an instance variable it sets is not there for the template below it. - Put a
cacheblock inside a<Suspense>, never around one. A cached fragment keeps the markers of the render that wrote it: a<Suspense>read back from the cache gets an error for that boundary (logged and reported), a client component read back from the cache fails the page with a message naming thecache. Acacheinside the content is written when it runs and read on the next load, still after the shell.
Scopes, transactions and connections
Section titled “Scopes, transactions and connections”The content runs while the action is still running, on a thread of its own.
- What the controller set up holds: an
around_action’s locale,Time.zone, tenant,CurrentAttributes, and a database role set withActiveRecord::Base.connected_toin anaround_action. - What a middleware set up does not. Rails’
DatabaseSelectorreads from the replica for the shell, and the content runs as a writer (measured with a primary and a replica); a locale a middleware set is not to be relied on. Set the locale, the tenant and the database role in the controller. - An action wrapped in a transaction holds its connection from the shell
to the last
<Suspense>, and commits after it. A transaction a<Suspense>opens itself commits, or rolls back when it raises. - A transaction left open at the end of an action is handed, open, to the next request that gets that connection — with or without streaming: that is Rails.
Errors and time
Section titled “Errors and time”- A
<Suspense>whose content raises keeps its fallback; the rest of the page stays and the next<Suspense>runs. The error goes toRails.errorand the log. In development every failure is shown over the page, with the template’s file and line; in production the browser is told only that a part failed. Ruact.config.suspense_timeout(5 seconds) bounds how long one<Suspense>’s content may run; past it, that boundary gets an error and the next one runs. With PostgreSQL, a query the content is still running at the limit is cancelled, so its connection is free again — except a queryload_asyncruns, which runs to its end. Bound queries with the database’s own statement timeout (statement_timeoutin PostgreSQL,max_execution_timefor MySQL reads), set belowsuspense_timeout: it is the real limit.delay="2"waits that many seconds before the content runs, inside the timeout.
Navigating while a page still streams
Section titled “Navigating while a page still streams”- Leaving it by a link or a form stops the document (
window.stop()), so the server starts no further<Suspense>for a page nobody sees. What that document was still loading stops too: an image still loading stays broken, afetchor a moduleimport()in flight fails. If the navigation then fails, the router fetches the page you are still on again. revalidate()stays on the page and does not stop it, so the page’s other requests in flight (a server function, auseQuery) are not cut.- A link or Back/Forward to an
#anchorof the page is left to the browser: it scrolls, nothing is fetched.
Deploying an app that streams
Section titled “Deploying an app that streams”- Behind nginx, set
proxy_http_version 1.1;where it proxies to the app. nginx talks HTTP/1.0 to its upstream by default,ActionController::Livecannot stream over it, and the page is sent whole — the app logs it once, andrails ruact:doctornames it. Keep theX-Accel-Buffering: noheader ruact sends: withgzip on, it is what keeps nginx from holding the page (measured on nginx 1.24). - Threads: a page that streams holds its server thread until its last
<Suspense>. Streaming changes when the browser gets the page, not how many slow requests the server holds: with every Puma thread on a streaming page, the next request waits — and so do those pages’ own JavaScript and CSS when Puma serves them. Size the threads for the slow pages, or serve the assets from elsewhere (a CDN, the proxy). - Content Security Policy: with a nonce generator
(
config.content_security_policy_nonce_generator), every script ruact writes carries the request’s nonce, so a policy without'unsafe-inline'works. In development,script-srcalso needs the Vite dev server’s origin, and React’s development build asks foreval()(it goes on without it). - Rack 2 works: ruact gives a streamed response an
ETagof its own, so Rack 2’sRack::ETagdoes not hold it whole. - Rebuild the JavaScript when you update the gem. The page a newer server
sends, read by an older build of ruact’s JavaScript, keeps its fallbacks.
The app says so: in development a banner across the page, at boot one error
line in the log (the app still boots), and
rails ruact:doctorfails its manifest check. Thevite.config.jsthatrails generate ruact:installwrites asks Bundler where the ruact gem is each time Vite starts, so afterbundle update ruacta rebuild uses the new plugin. An app installed with 0.0.21 or before has avite.config.jsthat imports the plugin by the path of the gem installed then: runrails generate ruact:installagain (it replaces that file when you never edited it, and prints the change otherwise). Vite has to run wherebundle execworks; a build stage without Ruby can setRUACT_VITE_PLUGIN_PATHto the plugin’sindex.jsinstead.
What a streamed action gives up
Section titled “What a streamed action gives up”A streamed action runs under ActionController::Live, applied to the declared
actions only:
- no
ETagof the app’s computed from the body, and anafter_actioncan no longer change the response; - one server thread is held while the page is open (above);
- the app’s own request and system tests do not see it stream: Rails’ test
support runs
ActionController::Livein place, and Rack::Test speaks HTTP/1.0 (the log says so, once).
What gets serialized
Section titled “What gets serialized”When your controller calls ruact_render, ruact:
- Evaluates the ERB template
- Collects all PascalCase component references (and their props)
- Serializes the result as a Flight wire format payload
- Sends the payload (or an HTML shell wrapping it, for first-page loads)
Only the values you pass as props reach the browser. A model you pass crosses as the fields you list with ruact_props. Without that list, development sends the model’s as_json (every attribute) and logs it; production, where strict_serialization is on by default, raises instead of sending it.
HTML elements
Section titled “HTML elements”Plain HTML elements in ERB are also converted to React elements. ruact uses Nokogiri to parse the HTML output and converts it to a React element tree:
<div class="post"> <h2><%= @post.title %></h2> <p><%= @post.body %></p></div>Becomes the React element tree ["$", "div", null, { className: "post", children: [...] }].
Note on
classvsclassName: ruact automatically convertsclass→className,for→htmlFor, and handlesvalue/checked→defaultValue/defaultCheckedfor uncontrolled form inputs.