Skip to content

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.


When you use a PascalCase tag in an ERB template, ruact treats it as a React element:

app/views/posts/index.html.erb
<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.


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

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.

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)
end
end

<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.
  • 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, call load_async in the controller and read the result inside the <Suspense>: the query runs while the shell is sent. That needs config.active_record.async_query_executor = :global_thread_pool — without it (a new Rails app’s default) load_async runs its query where it is called, and rails ruact:doctor warns. 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 an after_action once the page was sent; one that raises is reported to Rails.error.
  • content_for (and provide) 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 cache block 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 the cache. A cache inside the content is written when it runs and read on the next load, still after the shell.

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 with ActiveRecord::Base.connected_to in an around_action.
  • What a middleware set up does not. Rails’ DatabaseSelector reads 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.
  • A <Suspense> whose content raises keeps its fallback; the rest of the page stays and the next <Suspense> runs. The error goes to Rails.error and 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 query load_async runs, which runs to its end. Bound queries with the database’s own statement timeout (statement_timeout in PostgreSQL, max_execution_time for MySQL reads), set below suspense_timeout: it is the real limit.
  • delay="2" waits that many seconds before the content runs, inside the timeout.
  • 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, a fetch or a module import() 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, a useQuery) are not cut.
  • A link or Back/Forward to an #anchor of the page is left to the browser: it scrolls, nothing is fetched.
  • 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::Live cannot stream over it, and the page is sent whole — the app logs it once, and rails ruact:doctor names it. Keep the X-Accel-Buffering: no header ruact sends: with gzip 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-src also needs the Vite dev server’s origin, and React’s development build asks for eval() (it goes on without it).
  • Rack 2 works: ruact gives a streamed response an ETag of its own, so Rack 2’s Rack::ETag does 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:doctor fails its manifest check. The vite.config.js that rails generate ruact:install writes asks Bundler where the ruact gem is each time Vite starts, so after bundle update ruact a rebuild uses the new plugin. An app installed with 0.0.21 or before has a vite.config.js that imports the plugin by the path of the gem installed then: run rails generate ruact:install again (it replaces that file when you never edited it, and prints the change otherwise). Vite has to run where bundle exec works; a build stage without Ruby can set RUACT_VITE_PLUGIN_PATH to the plugin’s index.js instead.

A streamed action runs under ActionController::Live, applied to the declared actions only:

  • no ETag of the app’s computed from the body, and an after_action can 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::Live in place, and Rack::Test speaks HTTP/1.0 (the log says so, once).

When your controller calls ruact_render, ruact:

  1. Evaluates the ERB template
  2. Collects all PascalCase component references (and their props)
  3. Serializes the result as a Flight wire format payload
  4. 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.


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 class vs className: ruact automatically converts class → className, for → htmlFor, and handles value/checked → defaultValue/defaultChecked for uncontrolled form inputs.