Server functions
You already know how to write a Rails controller. A server function is one you can call from React — no new DSL, no generated endpoint, no client plumbing. You write the action; ruact gives React a typed function that calls it.
This page builds the whole model from a controller you’ve already written.
Start with a controller you already have
Section titled “Start with a controller you already have”Here is an ordinary Rails controller. Nothing ruact-specific yet:
class PostsController < ApplicationController def create @post = Post.create!(post_params) redirect_to @post end
private
def post_params params.require(:post).permit(:title, :body) endendIt’s routed the way you’d route anything:
resources :postsAdd one line
Section titled “Add one line”class PostsController < ApplicationController include Ruact::Server # ← the only new line
def create @post = Post.create!(post_params) redirect_to @post end # ...endinclude Ruact::Server makes this controller’s non-GET routed actions
callable from React. That’s the whole rule:
The verb decides. A routed action reached by
POST/PATCH/PUT/DELETEis a server function.GETpage actions are untouched — they keep rendering pages exactly as before.
There is no separate endpoint and no per-action declaration. The action is
the function, reachable at its real route (POST /posts), visible in
rails routes like everything else. Routed means public — the same way a Rails
route has always meant public.
The payoff
Section titled “The payoff”ruact generates a typed TypeScript module from your route table. Import the action by name and call it:
import { createPost } from "@/.ruact/server-functions";
const post = await createPost({ post: { title: "Hi", body: "…" } });You didn’t learn a framework. You wrote Rails, and React got a function. Write Rails. Ship React.
The export name is the camelCase form of the route (create on posts →
createPost); the naming rules are below. The import path
@/.ruact/server-functions never changes — ruact’s Vite plugin regenerates the
module whenever your routes change.
How the request picks the response shape
Section titled “How the request picks the response shape”The same action, at the same URL, answers different body shapes — and the
same controller answers a third when it renders a page. Which shape you get is
decided entirely by how the request arrived (its Accept header and HTTP
verb), never by the Ruby. You don’t choose a mode in your action — you write one
action and the caller picks the shape.
This table maps every caller to the shape it receives and the signal that decides it:
| Caller | Deciding signal (Accept + verb) |
Response shape | Content-Type |
|---|---|---|---|
Generated accessor — await createPost(args) |
application/json + the route’s non-GET verb |
function-call JSON (ivars, or 204 when none, or a $redirect directive) |
application/json (empty on 204) |
<form action={createPost}> (React 19 form action / useActionState) |
application/json + the route’s non-GET verb |
function-call JSON — same as the accessor | application/json |
| Client-side router navigation | text/x-component + GET |
Flight page — re-renders in place | text/x-component |
Native <form> a client router intercepts |
text/x-component + the form’s verb |
Flight page — re-renders in place | text/x-component |
Plain browser GET (first load / address bar) |
HTML-acceptable Accept (text/html or */*), no Flight header + GET |
HTML shell — full document with <div id="root"> + inline Flight data |
text/html |
Query — useQuery(...) / GET /q/<name> |
application/json + GET (the query dispatch route) |
plain JSON — the query method’s return value | application/json |
<form action={createPost}> is function-call JSON, not Flight. React 19’s
<form action> on a generated accessor still fetches with
Accept: application/json, so it lands in the same bucket as
await createPost(...) — the JSON shape, not a Flight re-render. This is the one
row people expect to be Flight and it is not.
The discriminator is the exact Accept header (plus the verb rule: a function
call is non-GET, so a query’s GET + application/json is a query, not a
function call). In development, ruact echoes this decision as a
[ruact] Controller#action — <signal> → <bucket> log line — one per negotiated
request — so you can watch which shape was served and why without a debugger.
The log speaks the same vocabulary as this table (e.g.
Accept: application/json + POST → function-call JSON). The dev log currently
covers Ruact::Server controllers (function calls + page renders); queries and
page-only controllers appear in the table above but are not yet echoed by the
log.
The JSON shape — instance variables
Section titled “The JSON shape — instance variables”When the action is called through the generated accessor and finishes without an
explicit render, ruact serializes the action’s instance variables (the
same set a view would see) into a JSON object keyed by ivar name:
def create @post = Post.create!(post_params) # imperative caller receives { "post": { … } }endconst { post } = await createPost({ post: { title: "Hi", body: "…" } });Each value goes through the same ruact_props / Ruact::Serializable /
strict_serialization policy as the rest of ruact (see
Ruact::Serializable). An action that assigns no ivars
answers 204 No Content (the call resolves to null).
Redirects
Section titled “Redirects”redirect_to works in both shapes. For a JSON call — the generated accessor,
including <form action={createPost}> — it surfaces as a $redirect
directive that the runtime follows client-side (through the router, with a hard
navigation as fallback). For a Flight navigation it is a Flight redirect. The
same redirect_to @post line serves both. ruact reuses Rails’ own open-redirect
protection, so a cross-host redirect raises UnsafeRedirectError exactly as it
would in a normal action.
Calling from React
Section titled “Calling from React”Forms — <form action={fn}>
Section titled “Forms — <form action={fn}>”Pass the imported action straight to React 19’s <form action> prop. On submit,
React calls it with the form’s FormData; the runtime sends it as
multipart/form-data to the action’s real route using that route’s verb,
forwards CSRF, and resolves with the action’s JSON. React discards a form
action’s return value, so to use the result, read it with useActionState
(below) or refresh the surrounding page with revalidate():
import { createPost } from "@/.ruact/server-functions";
export function NewPost() { return ( <form action={createPost}> <input name="post[title]" required /> <textarea name="post[body]" required /> <button>Create</button> </form> );}Inside create, the submitted fields arrive as normal request params —
params.require(:post).permit(:title, :body) works as always.
useActionState
Section titled “useActionState”To read the action’s result into React state, wrap it so React’s
(prevState, formData) call shape forwards just the FormData:
import { useActionState } from "react";import { createPost } from "@/.ruact/server-functions";
type PostState = { post: { id: number; slug: string } } | null;
export function PostForm() { const [state, formAction, pending] = useActionState<PostState, FormData>( async (_prev, formData) => (await createPost(formData)) as PostState, null, );
return ( <form action={formAction}> <input name="post[title]" required /> <textarea name="post[body]" required /> <button disabled={pending}>{pending ? "Saving…" : "Create"}</button> {state && <p>Created post #{state.post.id}</p>} </form> );}The wrapper exists because the generated action type takes a single argument
while useActionState calls its function with two; (_prev, fd) => createPost(fd)
forwards only the FormData (prev-state is client-only React state, never sent
to the server). If a future React release converges the two call shapes, the
wrapper drops out.
Direct calls and argument shapes
Section titled “Direct calls and argument shapes”You can also call an action directly from an event handler. The runtime picks the wire encoding from the argument:
| JS argument | Wire encoding | Server-side params |
|---|---|---|
plain object / null / undefined |
application/json |
decoded from the JSON body |
FormData |
multipart/form-data (browser-managed boundary) |
decoded from the multipart body |
Dynamic segments
Section titled “Dynamic segments”When the route has a dynamic segment (PATCH /posts/:id), the runtime reads it
by name from your single call argument and interpolates it into the URL;
the full argument is still sent as the body, so params[:id] and the rest both
arrive:
import { updatePost } from "@/.ruact/server-functions";
await updatePost({ id: 7, post: { title: "Edited" } }); // PATCH /posts/7Revalidating after a mutation — revalidate()
Section titled “Revalidating after a mutation — revalidate()”An imperative call updates React state but doesn’t refetch the page from Rails.
When a mutation changed data shown elsewhere on the page, call revalidate():
import { createPost, revalidate } from "@/.ruact/server-functions";
async function handleSubmit(formData: FormData) { await createPost(formData); await revalidate(); // refetch the current path as Flight, in place}It refetches the current path (or a path you pass) as a Flight stream and swaps
the tree in place — no history entry, no scroll. Mirrors Next.js’
revalidatePath. It requires the ruact router to be installed (setupRouter()
publishes the handle at boot) and throws a descriptive error if it isn’t,
rather than silently doing nothing.
Auto-revalidate — one await = mutation + refresh
Section titled “Auto-revalidate — one await = mutation + refresh”Remembering the second await revalidate() is exactly the kind of step that’s
easy to drop — the mutation succeeds, the page looks stale, and it reads like a
silent bug. Auto-revalidate is an opt-in that folds the refresh into the
mutation call: after a successful, non-redirecting mutation it runs the
in-place Flight refresh of the current path for you, before your await
resolves. It’s the same two requests as the manual two-step — the POST-JSON
mutation and the GET-Flight refresh — just composed for you. There’s no new
wire shape: auto-revalidate never adds inbound Flight parsing.
There are two ways to opt in.
Per call — withRefresh(accessor). Wrap a generated accessor to refresh
after that one call. Import withRefresh from the runtime (the same place
configureRuactRuntime comes from):
import { createPost } from "@/.ruact/server-functions";import { withRefresh } from "ruact/server-functions-runtime";
async function handleSubmit(formData: FormData) { await withRefresh(createPost)(formData); // create, then refresh, in one await}App-wide — configureRuactRuntime({ autoRevalidate: true }). Flip it on
once at boot and every successful, non-redirecting mutation refreshes:
import { configureRuactRuntime } from "ruact/server-functions-runtime";
configureRuactRuntime({ autoRevalidate: true }); // default is falseA per-call withRefresh(...) wins over the global default, so you can force
a refresh on a single call even with the app-wide default off.
The behavior in the edge cases mirrors revalidate() exactly:
- A
$redirectresponse wins. When the actionredirect_tos, ruact follows the redirect and skips the refresh — the destination already re-renders, so refreshing the old path would be wrong. The call resolvesnull, as it does without auto-revalidate. - A failed mutation never refreshes. If the mutation itself fails (a
422, a transport error), the error propagates and no refresh is attempted — the refresh is sequenced strictly after the mutation succeeds. - No router → the same loud error. Auto-revalidate reuses
revalidate(), so with no router installed you get the same descriptive “wiresetupRouter()” error rather than a silent no-op. - Refresh failure rejects the
await. Because the refresh uses the same loud-by-defaultrevalidate(), a mutation that succeeds but whose refresh then fails rejects yourawait createPost(...). That’s deliberate — a stale page is surfaced, not swallowed — but it means a rejected promise no longer implies the mutation failed.
Validation errors
Section titled “Validation errors”When a save raises — Post.create! on an invalid record — that exception
flows through the structured-error path (a 422
with the dev overlay). But the idiomatic Rails shape is the non-exception
one: if @post.save … else …, where save returns false and the action does
not raise. For that happy-failure path, ruact gives you an Inertia-style
errors round-trip — field-level messages back to the form, no per-action error
channel to hand-roll.
It’s opt-in and explicit, like everything else on the wire: you call
ruact_errors(@post) once after the save attempt. Because the shape is derived
from record.errors (empty on a valid record), the same call yields {} on
success and the populated map on failure — so the client has one code path for
both.
The shape is always { [attribute]: string[] } — attribute names as strings
(a model-level error keys under "base"), values arrays of full messages. On a
valid record ruact_errors returns {} (never nil), so the reader — and the
errors prop a form binds to (below) — is always safe to read on both branches.
On the wire, result.errors appears whenever the action falls through after
ruact_errors; a success branch that redirect_tos navigates instead of
resolving a result (see Redirects).
Imperative caller (await) — register the errors and let the action fall
through (no explicit render/redirect_to on the failure branch); ruact’s
implicit render injects them into the JSON body under an errors key, alongside
any instance variables:
class PostsController < ApplicationController include Ruact::Server
def create @post = Post.new(post_params) if @post.save redirect_to @post else ruact_errors(@post) # fall through → { "errors": { "title": ["Title can't be blank"] } } end endendconst result = await createPost({ post: { title: "" } });if (Object.keys(result.errors).length) { setErrors(result.errors); // { title: ["Title can't be blank"] }}The fall-through rule
Section titled “The fall-through rule”Whether errors reaches the imperative caller comes down to how the action
ends on the failure branch. The two branches, side by side:
Fall through → errors injected. Register, then let the implicit
default_render run. ruact injects the collected map under errors in the JSON
body (alongside any instance variables):
def create @post = Post.new(post_params) return redirect_to(@post) if @post.save
ruact_errors(@post) # fall through → body gets { "errors": { … } }endExplicit render → injection opted out. An explicit render on a
function-call (imperative-caller) request bypasses default_render, so the
registered errors are silently dropped from the response:
def create @post = Post.new(post_params) return redirect_to(@post) if @post.save
ruact_errors(@post) render json: { ok: false } # ⚠️ errors are NOT injected — they vanishendIn development, ruact catches this exact shape and logs one
[ruact]-prefixed warning naming the controller#action and the fix, so the
trap surfaces at dev time instead of as a silent empty errors in the browser.
It is log-only — the response is byte-identical in every environment.
Two branches never trip the warning because they are the correct ways to
surface errors alongside an explicit response: a page render that binds
errors={ruact_errors} (the form re-render below), and a redirect_to that
carries the errors through flash (also below). An action that never calls
ruact_errors is unaffected — no errors key is added, and an empty action
still answers 204 as before.
Form / navigation (redirect-back) — the Inertia “redirect back with errors”
pattern. Register the errors, then redirect; ruact stashes them in flash
(single-use) so they survive the redirect and arrive as an errors prop on the
re-rendered page:
def create @post = Post.new(post_params) if @post.save redirect_to @post else ruact_errors(@post) redirect_to new_post_path # errors survive in flash endend<%# app/views/posts/new.html.erb %><PostForm errors={ruact_errors} />The no-argument ruact_errors reads the always-present hash ({} when there’s
nothing to show), so the same <PostForm> works on a first visit and after a
failed submit. This path needs the host’s session middleware (standard for form
apps); an API-only host without sessions uses the body path above instead.
This extends the structured-error chain — it doesn’t replace it. A raised
ActiveRecord::RecordInvalid is still a 422 for the overlay; ruact_errors
is the if save … else path that returns a normal 200/redirect with field
messages the form binds to.
Reading data — queries
Section titled “Reading data — queries”Mutations are controller actions. Reads are query classes — the same “write Rails” idea, on the GET side.
Define a query class under app/queries/. Each public instance method is one
query:
class ApplicationQuery < Ruact::Query; end
# app/queries/catalog_query.rbclass CatalogQuery < ApplicationQuery def categories Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } } end
def search_users(q:) User.where("name ILIKE ?", "%#{q}%").limit(10).as_json(only: %i[id name]) endendMount it in routes.rb — one line draws one named GET route per public method,
visible in rails routes:
ruact_queries CatalogQuery # GET /q/categories, GET /q/searchUsersThe route prefix defaults to /q and is configurable via
Ruact.config.query_route_prefix (see Configuration).
useQuery
Section titled “useQuery”Read a query from React with the useQuery hook. It returns
{ data, loading, error }:
import { categories, searchUsers, useQuery } from "@/.ruact/server-functions";
function CategoryPicker() { const { data, loading, error } = useQuery(categories); if (loading) return <Spinner />; if (error) return <p>Couldn't load categories.</p>; return <Select options={data} />;}
function UserSearch({ query }: { query: string }) { const { data } = useQuery(searchUsers, { q: query }); return <Results users={data ?? []} />;}loading is true until the first response; error carries the thrown error
on failure — usually a structured RuactActionError for a non-OK server
response (see Errors), or a TypeError /
transport Error for a rejected parameter or a network failure. The hook
refetches when the params change by value.
Query parameters
Section titled “Query parameters”A query method’s keyword arguments are its parameters, passed from useQuery’s
second argument. The wire is a GET query string, so only primitive values
are accepted — string, number, boolean, null. Arrays and objects are
rejected with a descriptive error naming the offending key; a missing required
keyword argument or an unknown parameter is a 400 Bad Request. Reads are
CSRF-free — a GET carries no body and no CSRF token.
Typed accessors
Section titled “Typed accessors”The generated module types each query accessor’s params from the query
method’s declared keyword arguments — you get real parameter types with
zero hand-written annotations. A query
class CatalogQuery < ApplicationQuery def search_users(term:, limit: 10) User.search(term).limit(limit) endendemits a typed accessor:
// in the generated @/.ruact/server-functions moduleexport const searchUsers: (params: { term: string | number | boolean | null; limit?: string | number | boolean | null;}) => Promise<unknown>;so the call site is checked by tsc:
useQuery(searchUsers, { term: "ada" }); // ✅ okuseQuery(searchUsers, { term: "ada", limit: 25 }); // ✅ okuseQuery(searchUsers, { limit: 25 }); // ❌ missing required `term`useQuery(searchUsers, { term: "ada", page: 2 }); // ❌ unknown key `page`Required keywords (term:) become required properties; keywords with a
default (limit: 10) become optional properties — and the editor
autocompletes the parameter names. A query that declares no keyword arguments
keeps the bare () => Promise<unknown> signature.
Each parameter’s value type is the wire union string | number | boolean | null — the exact set the GET query string can carry. Ruby is dynamically
typed, so the generator reports parameter names and required/optional-ness (what
reflection can honestly see), not per-parameter scalar types: limit? is string | number | boolean | null, not number. The return type stays
Promise<unknown> — annotate the result yourself with useQuery’s generic:
useQuery<User[]>(searchUsers, …).
Action accessors are unchanged: an action reads params dynamically (there is no
declared-input list to reflect), so it keeps its FormData | Record<string, unknown> argument and a Promise<unknown> return.
Reads run inside your real security context
Section titled “Reads run inside your real security context”Query dispatch runs through an internal controller that inherits
Ruact.config.query_parent_controller (default ApplicationController), so the
host’s real callback chain — authenticate_user!, tenant scoping, Pundit —
runs before the query object is instantiated. Inside a query, current_user,
params, request, and session delegate to the dispatching controller, so
current_user is the host’s own method:
class CatalogQuery < ApplicationQuery def my_categories current_user.categories.pluck(:id, :name) # the host's current_user endendBecause the context is constructor-injected, a query is unit-testable with no
Rails boot: CatalogQuery.new(fake_context).categories.
Request de-duplication
Section titled “Request de-duplication”When several components mount useQuery(categories) with the same parameters
while a request is in flight, they share one network request — three
components needing categories issue one GET, not three, and all receive the
same result (or the same error). De-duplication is in-flight only: there is
no cache and no stale-while-revalidate. Once a request settles, a fresh mount
issues a fresh request.
Record references — signed, scoped, expiring
Section titled “Record references — signed, scoped, expiring”When you pass a record to the client, don’t send a raw id. A raw id (or an
attribute hash) is a forgeable reference: nothing stops a client from changing
{ "id": 7 } to { "id": 8 } and submitting it back. If your action then trusts
params[:id] and loads Post.find(params[:id]), it just reached a record the
user was never authorized to touch — the classic forged-reference (IDOR) attack.
ruact’s answer is a SignedGlobalID token: HMAC-signed by your app secret
(tamper-proof), bound to a for: purpose (so a token minted for editing a
post can’t be replayed against, say, a delete endpoint), and expires_in:
time-bounded. The client receives an opaque string it cannot forge or
repurpose.
Mint one with Ruact.signed_global_id and resolve it back with
Ruact.locate_signed:
class PostsController < ApplicationController include Ruact::Server
# Producing the reference (e.g. for an edit form's hidden field) def edit @post = current_user.posts.find(params[:id]) @post_ref = Ruact.signed_global_id(@post, for: :post_edit, expires_in: 1.hour) end
# Receiving it back from the client def update post = Ruact.locate_signed(params[:post_ref], for: :post_edit) post.update!(post_params) redirect_to post endendOn the client the token is just a string — drop it in a hidden field (or pass it to an imperative call):
<form action={updatePost}> <input type="hidden" name="post_ref" value={postRef} /> {/* ...fields... */}</form>What you get for free. A token that is tampered, expired, or
presented for the wrong purpose fails verification: Ruact.locate_signed
raises Ruact::InvalidSignedGlobalIDError, which ruact renders as a clean
400 Bad Request through the structured-error
chain — no ActiveRecord::RecordNotFound leak, no raw-id trust, and (because
verification fails before any database lookup) no signal about whether the target
record even exists.
The contract — purpose and expiry are required. ruact refuses to silently
mint an unscoped or non-expiring token, because that would be a silent
insecure default. Pass for: and expires_in: per call, or set app-wide
defaults:
Ruact.configure do |c| c.signed_global_id_default_purpose = :ruact_ref c.signed_global_id_default_expires_in = 15.minutes # an ActiveSupport::DurationendIf neither the call nor the config supplies a purpose (or an expiry), the helper
raises loudly rather than hand out a weak token. To deliberately mint a
non-expiring token, pass an explicit expires_in: nil at the call site — a
reviewed, per-call choice, never a default.
This is opt-in: ruact never auto-converts records in your props or auto-resolves params. You reach for the helper where a reference crosses a trust boundary — exactly the record references the generated CRUD scaffold will emit.
For mutations, the runtime forwards the page’s <meta name="csrf-token"> value
as X-CSRF-Token, and your host’s protect_from_forgery enforces it — the same
mechanism a Rails form uses. There is no ruact-specific CSRF policy.
Include order matters. Add
include Ruact::Serverafterprotect_from_forgery(the usual case —protect_from_forgerylives inApplicationController, your controller includes the concern lower down), so the forgery check sits ahead of the action in the callback chain.
- Classic Rails. Layouts emit the CSRF meta tag via
csrf_meta_tags; the runtime reads it automatically. Nothing to wire. - API mode (
ActionController::API, noprotect_from_forgery). No meta tag; the runtime omits the header and the request is accepted — auth is the host’s responsibility. Reads (queries) are CSRF-free in every mode.
Per-request auth headers — configureRuactRuntime
Section titled “Per-request auth headers — configureRuactRuntime”API-mode apps that authenticate with a bearer token attach it once at boot.
Since ruact’s bootstrap entry is hidden plumbing (a virtual module — there is no
app/javascript/application.jsx to edit), call configureRuactRuntime at module
scope in one of your own "use client" modules. Those modules are bundled into
your app and their top-level code runs when the bundle loads, before any server
function is ever called:
// app/javascript/components/RuntimeConfig.jsx ("use client", bundled at boot)"use client";import { configureRuactRuntime } from "ruact/server-functions-runtime";
// static token:configureRuactRuntime({ defaultHeaders: { Authorization: `Bearer ${window.AUTH_TOKEN}` },});
// function form — re-evaluated on every request so refreshed tokens win:configureRuactRuntime({ defaultHeaders: () => ({ Authorization: `Bearer ${authStore.token()}` }),});The import path is always ruact/server-functions-runtime (the gem’s Vite
plugin installs that alias). Three guarantees on top of defaultHeaders:
- On mutation calls, ruact owns
Accept,Content-Type, andX-CSRF-Token. Any value you pass for those keys is stripped (case-insensitively), so you can’t downgrade JSON negotiation or break the multipart boundary. (Query GETs reserve onlyAccept— they carry no body and send no CSRF token regardless.) - The function form runs once per request — use it for tokens that refresh at runtime.
defaultHeaders: nullclears previously-set headers (handy in tests).
Errors and the dev overlay
Section titled “Errors and the dev overlay”When a server function raises, ruact converts the exception into a structured JSON body so React can show a rich dev overlay or render its own UI. The body has the same shape in both modes; production drops the dev-only fields.
In development and test (Ruact.config.dev_error_payload_enabled defaults to
true there):
{ "_ruact_server_action_error": true, "action_name": "create", "error_class": "ActiveRecord::RecordInvalid", "message": "Validation failed: Title can't be blank", "app_frames": ["/…/app/controllers/posts_controller.rb:5:in `create'"], "gem_frames": ["/…/lib/ruact/…"], "suggestion": "Validation failed — check the model's `validates` rules", "validation_errors": ["Title can't be blank"]}In production the payload is just the four baseline fields
(_ruact_server_action_error, action_name, error_class, message); the
diagnostic keys are absent (not null). The dev overlay shows the action name,
an error-class chip, the message, the validation list, the suggestion, app
frames by default, gem frames behind a toggle, and a copy-to-clipboard button.
Force the production shape locally to verify what your component will receive:
Ruact.configure { |c| c.dev_error_payload_enabled = false }Status mapping
Section titled “Status mapping”| Exception | HTTP status |
|---|---|
ActiveRecord::RecordInvalid |
422 |
ActionController::InvalidAuthenticityToken |
403 |
Ruact::UploadTooLargeError |
413 |
any other StandardError |
500 |
The runtime rejects with a RuactActionError carrying status, body,
actionName, and the raw response, so React can branch on error.status.
Your rescue_from wins
Section titled “Your rescue_from wins”ruact’s structured handler is the outermost catch — a host
rescue_from you declare (or inherit) takes precedence for the exceptions it
owns. ruact only renders the structured payload for exceptions the host did not
catch. Use this to give an owned error your own JSON shape, or to convert a
failure into a 200 with inline state:
class PostsController < ApplicationController include Ruact::Server rescue_from BillingDeclined, with: :handle_billing_declined
def create @post = Post.create!(post_params) end
private
def handle_billing_declined(error) render json: { code: "billing_declined", retry_at: error.retry_at }, status: :payment_required endendRendering errors inline in React
Section titled “Rendering errors inline in React”A component that wraps an action in useActionState can catch the rejection and
render its own UI — works identically in development (the overlay does not fire
for a caught error) and production:
async function submit(_prev, formData) { try { return { post: await createPost(formData), error: null }; } catch (err) { return { post: null, error: err.body }; // RuactActionError carries the structured body }}The payload deliberately omits per-request context — request.env, params,
session, cookies, and headers are never serialized, to avoid leaking PII.
The full backtrace and request context are in the server log (always; the
dev_error_payload_enabled toggle only governs the wire body).
File uploads
Section titled “File uploads”A <form action={fn}> containing <input type="file"> works without setting
enctype: React 19 builds a FormData that includes the file, the runtime
sends it as multipart/form-data to the action’s route (using that route’s
verb), and Rails’ multipart parser delivers the file as an
ActionDispatch::Http::UploadedFile. Active Storage works unchanged:
class PostsController < ApplicationController include Ruact::Server
def create @post = Post.new(title: params[:title]) @post.cover.attach(params[:cover]) @post.save! endendimport { createPost } from "@/.ruact/server-functions";
export function CreatePost() { return ( <form action={createPost}> <input name="title" /> <input type="file" name="cover" accept="image/*" /> <button>Create</button> </form> );}max_upload_bytes
Section titled “max_upload_bytes”ruact rejects oversized uploads before Rack’s multipart parser runs, against
the request’s Content-Length:
Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 } # 25 MBThe default is 10 MB; set it to nil to disable the gem-side guard and let your
reverse proxy own the cap. An oversized request gets 413 Payload Too Large
with the structured error body (adding a dev-only
upload_limit: { received_bytes, limit_bytes } block). The guard short-circuits
for non-multipart/urlencoded content types, requests without a Content-Length,
and max_upload_bytes = nil.
Use Direct Upload for big files
Section titled “Use Direct Upload for big files”max_upload_bytes is a “fail fast” knob, not a streaming upload pipeline (Rack
buffers the body before ruact sees it). For large files, route uploads through
Active Storage Direct Upload
or a presigned S3 URL, and set an operational cap at your reverse proxy
(client_max_body_size in nginx, LimitRequestBody in Apache).
Multipart text fields arrive as UTF-8 strings — Rails does not coerce them to
Boolean/Integer even for type="number"/type="checkbox" inputs. Cast them
in your action (ActiveModel::Type::Boolean.new.cast(params[:published]),
params[:count].to_i).
Naming
Section titled “Naming”Ground truth: read the generated file. The generated
app/javascript/.ruact/server-functions.tsis the authoritative list of every accessor and its typed params. When in doubt about an accessor’s name — or whether it exists at all — read that file instead of deriving the name in your head: autocomplete for humans,cat app/javascript/.ruact/server-functions.tsfor agents. Regenerate it withbin/rails ruact:server_functions:generate.
The generated export name is derived from the route. If two routes would
produce the same JavaScript identifier, codegen fails loudly at boot — break the
tie (or just pick a different name) with ruact_function_name:
class PostsController < ApplicationController include Ruact::Server ruact_function_name :publish_all, as: "publishEverything"endThe target must be a valid JS identifier and not a reserved word or a name the runtime already binds; an invalid override fails at class-load time, not at codegen.
Query export names derive from the method name the same way (def search_users
→ searchUsers); rename a query by renaming its method.
See also
Section titled “See also”Ruact::Controller— the page-rendering concernRuact::Serializable— how return values and instance variables are serialized- Configuration —
max_upload_bytes,dev_error_payload_enabled,query_route_prefix,query_parent_controller