rails generate ruact:scaffold
ruact:scaffold generates a complete CRUD skeleton on the route-driven v2 contract — at parity with rails generate scaffold, with typed React components as the differentiator. It is self-contained: it first delegates the model, migration, resources route and host-framework tests to Rails' own resource generator, then overlays the ruact layer (controller, ERB views, React components, query) on top. No manual rails generate model step.
rails generate ruact:scaffold Post title:string body:text published:booleanThis produces:
| File | What it is |
|---|---|
app/controllers/posts_controller.rb | The seven RESTful actions, include Ruact::Server. GET actions set ivars only (implicit default_render); create/update redirect on success (server-driven $redirect) and surface the FR98 ruact_errors channel on failure; destroy returns the in-list { ok: true } shape (no redirect) so the List removes the row in place. |
config/routes.rb | resources :posts (injected idempotently). |
app/javascript/components/PostList.tsx | A plain, design-system-agnostic native <table> (dep-free) — type-aware cells, a generated client-side sort, per-row actions, and a client-driven search box. Server-rendered rows arrive as props. |
app/javascript/components/PostForm.tsx | A shared new/edit form keyed on initial == null; native controls per attribute type (<input> / <textarea> / checkbox / <select>); inline per-field errors. |
app/javascript/components/PostDeleteDialog.tsx | A controlled, agnostic confirmation built on the native <dialog> element; on success the row is removed in place, on failure the dialog stays open with the message inline. |
app/queries/posts_query.rb | The read side: PostsQuery < ApplicationQuery with search(q:), backing the List's search box. |
app/queries/application_query.rb | The query base (< Ruact::Query), created once and never clobbered on re-scaffold. |
config/routes.rb | resources :posts and ruact_queries PostsQuery (both injected idempotently). |
app/views/posts/{index,show,new,edit}.html.erb | Render the components from controller ivars (no as_json in the view). |
spec/requests/posts_spec.rb | A light controller smoke spec. |
Prerequisite: run
rails generate ruact:installfirst — it scaffoldsapp/javascript/.ruact/and primes the route-driven codegen that emits thecreatePost/updatePost/destroyPostaccessors the components import. The model is not generated (it stays orthogonal to your schema); runrails generate model Post …+rails db:migrateas usual.
The List is a native <table> (see The list: table + search), the Form renders native controls per attribute type (see The form: controls by type), and the DeleteDialog is a controlled native <dialog> (see The delete confirmation).
No design-system prerequisite (the default is agnostic)
The default scaffold is design-system-agnostic: it emits plain React components built on native HTML elements — <table>, <input>, <textarea>, <select>, <input type="checkbox">, <button>, and a native <dialog> for the delete confirmation — styled by your app's default CSS. There are no @/components/ui/* imports, no Tailwind utility classes, and no shipped stylesheet. A fresh rails new app scaffolds and runs with zero UI-library setup — no components.json, no npx shadcn, no pre-flight, no abort. This is the frictionless first run.
shadcn is an opt-in — pass --shadcn
Epic 10 proved that shadcn/ui components drop into a Rails app cleanly — the right proof, but the wrong default for a first run (it required installing shadcn before the very first scaffold). shadcn is therefore an explicit opt-in, not the default:
rails generate ruact:scaffold Post title:string body:text published:boolean --shadcnWith --shadcn the generator emits the richer Epic 10 components instead of the agnostic ones — the same CRUD behavior, a different visual primitive:
PostList→ a styled shadcnTable(still dep-free — a generated client-side sort, no@tanstack/react-table), with the per-row actions collapsing into a responsiveDropdownMenuunder themdbreakpoint.PostForm→ shadcn form controls mapped per attribute type (Input/Textarea/Switch/Select+Label+Button).PostDeleteDialog→ a controlled shadcnAlertDialog.
Everything else — the controller, route, query, ERB views, the model/migration/test delegation, the smoke spec, and the FR99 type <Model>Row + FR100 __ruactContract — is identical to the agnostic default. --shadcn --javascript composes too: untyped .jsx shadcn output (forfeiting FR99/FR100, like any --javascript run).
The dependency pre-flight (runs only under --shadcn). Because the shadcn components import from @/components/ui/*, --shadcn first runs a dependency pre-flight. If shadcn/ui is not set up in the app (no components.json, or a required primitive is missing) it prints the copy-pasteable setup commands and aborts before writing anything (zero partial state) — there is no silent fallback to the agnostic output when you explicitly ask for shadcn:
npx shadcn@latest init
npx shadcn@latest add button input textarea switch select label badge table alert-dialog dropdown-menu(The exact add list is derived from your model's controls, so it never drifts from what the components import.) Set shadcn up, then re-run. To generate the shadcn components anyway before installing the primitives, pass --skip-shadcn-check: the files are written with an in-file banner naming the unresolved imports (the Vite build then fails loud on the missing import — never a silent blank render). --skip-shadcn-check is meaningful only under --shadcn; the agnostic default runs no pre-flight and never aborts.
Which to use.
| Use the agnostic default (no flag) when… | Use --shadcn when… |
|---|---|
| You want a frictionless first run with zero UI-library setup | shadcn/ui is already configured in the app (components.json + @/components/ui/*) |
| The app has no shadcn/ui (or uses a different design system / its own CSS) | You want the richer styled Table / form controls / AlertDialog output out of the box |
| You will style the plain native markup yourself | You are standardizing the app on shadcn/ui |
The sections below describe the agnostic default output; under --shadcn the same data flow renders through the shadcn primitives instead.
The list: table + search
<Model>List renders a plain native <table> plus a small generated client-side sort. There is no design-system or table-engine dependency: the scaffold stays dep-free (no @tanstack/react-table, no @/components/ui/*), matching the dep-free Form and the native date inputs.
Type-aware cells, one column per attribute. The generator unrolls a <th> + <td> per attribute, rendering each cell by attribute type. Every column header is a client-side sort toggle driven by a small generated comparator + a useState({ key, dir }) (the dataset is the controller's index payload — server-side sort/pagination is Phase-3 territory). The comparator compares numbers numerically, dates by time, booleans/strings sensibly, and sorts null/undefined values last regardless of direction (a blank cell never jumps to the top):
| Attribute type | Cell |
|---|---|
string / text | plain text |
boolean | plain "Yes" / "No" text |
integer / float / decimal / references | numeric |
date / datetime | locale-formatted |
A trailing actions column gives each row an Edit link and a Delete button that opens <Model>DeleteDialog. When the list is empty (and no search is active) it shows a "No <plural> yet" empty state — override the text with the emptyLabel prop.
Props vs. query — where each belongs. The initial list is server-rendered as props: PostsController#index sets @posts, the view serializes it to rows, and <PostList posts={rows} /> ships them inline in the Flight payload — no client query, no loading flash, no as_json in the view. A query enters only where the client drives the read: the search box. The generator emits that read path as a real Ruact::Query, not a re-as_json of an ivar:
app/queries/posts_query.rb—class PostsQuery < ApplicationQuerywith a publicsearch(q:)method (FR88 kwargs) that matches case-insensitively over the model's string/text columns and returns the same row shape the index serializes (so search results and the server-rendered props are interchangeable). A blankqreturns the whole collection.app/queries/application_query.rb—class ApplicationQuery < Ruact::Query, the host base every query inherits.ruact:installdoes not ship it, so the first scaffold creates it; a second scaffold never clobbers a customized base.config/routes.rb—ruact_queries PostsQueryis injected (idempotently), which mountssearchasGET /q/search.
The List wires the box with useQuery(searchPosts, { q }) (the codegen exports a generic search, aliased to search<Plural>): while q is non-blank it shows the typed query results, otherwise the server-rendered posts.
The form: controls by type
<Model>Form is the shared new/edit form (one component, keyed on initial == null). Each attribute renders the native control mapped to its type — <input> / <textarea> / checkbox / <select> (see the attribute-types table) — in a consistent label + control + inline-error layout. The per-field error is driven by the FR98 attribute-keyed errors map via errorsFor(attr), with a top-level base block above the fields.
Dependency-free controlled state. The form holds each field in a useState and binds the native input elements directly — there is no react-hook-form / zod and no design-system runtime dependency. Client-side validation is intentionally out of scope here (it is server-only, full round-trip). If you later want rich client validation, swap the controlled state for react-hook-form's useForm and feed these same FR98 errors into setError per key — an opt-in, not the default.
Native date/datetime inputs. date and datetime render as <input type="date"> / <input type="datetime-local">. Their native value formats already match the wire formats the controller serializes (YYYY-MM-DD / YYYY-MM-DDTHH:MM), so edit pre-fills and create submits round-trip for free.
references → a native <select> of parent options. The generated new / edit controller actions load a capped, labelled options list for each references field and pass it to the form as a prop:
# in PostsController#new and #edit
@author_options = Author.limit(101).map { |record| { "id" => record.id, "label" => record.try(:name) || record.try(:title) || record.to_s } }The view hands it to the form (<PostForm initial={initial} authorOptions={author_options} />) and the <select> renders one <option> per option. The label falls back name → title → to_s; adjust the column or the limit in the generated action to taste (the threshold is the generator's REFERENCE_OPTIONS_LIMIT, default 100). For a parent set larger than the threshold an eager <select> is the wrong control — a server-search combobox is. That path is a documented opt-in follow-up: it needs a parent-options read query the resource scaffold does not generate (you scaffold Post, not its Author parent), so wire it against a query on the parent model when you need it.
Server-driven navigation. On success the controller's redirect_to drives navigation (Ruact::Server emits { "$redirect": "<path>" }, the accessor follows it) — handleSubmit builds no URL and never calls window.location.assign. On a validation failure the same response carries the keyed errors map and the client stays on the form.
TypeScript by default
Components are emitted as .tsx, typed against the server boundary (FR99) and carrying an opt-in __ruactContract declaration (FR100) so call sites are validated at preprocess time.
rails generate ruact:scaffold Post title:string --javascript--javascript emits .jsx instead. This is an escape hatch: the JS path cannot surface the FR99 typed accessors or the FR100 call-site contract, so prefer the default unless you have a reason not to.
Attribute types
Each field:type argument maps to a TypeScript wire type (the FR99 wire-union grain — string | number | boolean | null) and a plain HTML form control. An unrecognized type fails the generator before any file is written, listing the supported set.
type | TS wire type | Form control (native) |
|---|---|---|
string | string | <input type="text"> |
text | string | <textarea> |
integer | number | <input type="number"> |
float | number | <input type="number"> |
decimal | number | <input type="number"> |
boolean | boolean | <input type="checkbox"> |
date | string | <input type="date"> |
datetime | string | <input type="datetime-local"> |
references | number | <select> of parent options (<name>_id) |
See The form: controls by type above for the full mapping and the references-options behaviour.
The delete confirmation
<Model>DeleteDialog is a controlled confirmation built on the native HTML <dialog> element: the List owns its open state per row and supplies an onConfirm callback. The component drives the modal off open (showModal() / close()). The dialog reads Delete "<title>"? (the model's first string display attribute — falling back to a text column, then id — never a hard-coded title) with a This action cannot be undone. body, and a Cancel / Delete footer. Escape fires the native close event, which the component forwards to onOpenChange(false) without deleting.
There are two server-owned success shapes, the navigation decision on the server in both:
- Delete-in-list (the generated default).
#destroyreturns{ ok: true }(no redirect).onConfirmcallsdestroy<Model>({ id })(DELETE/<plural>/:id) and, on success, removes the row from the List's local state in place — no full reload, no URL built. Removing a row from a list you are already on is not navigation. - Delete-from-show (commented opt-in). When a delete happens off a detail page and should land back on the index, uncomment the
redirect_to <plural>_urlline in#destroy; thedestroy<Model>accessor follows the emitted{ "$redirect": "/<plural>" }automatically — the component still builds no URL.
There is no Rails method=delete form fallback — the destroy<Model> accessor is the only path.
On failure (e.g. restrict_with_exception on a foreign key, or any raised error) #destroy does not hand-roll a rescue: the gem's structured-error middleware shapes the response (the exception message in development, the generic server message in production), the destroy<Model> accessor throws it, and the dialog stays open with the message inline — the user never loses their place.
Server-driven navigation & errors
The generated controller keeps the navigation decision on the server: create/update call redirect_to on success, which Ruact::Server emits as { "$redirect": "<path>" } for the runtime to follow — the React components never build URLs. destroy returns the in-list { ok: true } shape by default (with a commented redirect_to alternative — see The delete confirmation). On a validation failure, ruact_errors(@post) (FR98) attaches an attribute-keyed errors map to the Bucket-2 JSON, which PostForm surfaces inline.
Tamper-proof references (FR96, opt-in)
The generated default addresses records by the familiar RESTful raw :id. SignedGlobalID references are a one-line, commented opt-in throughout the generated controller (Ruact.signed_global_id to mint, Ruact.locate_signed to resolve, plus a commented publish action addressed only by a signed token). Uncomment them per endpoint where you want a tamper-proof handle — never imposed, never closing off the raw-:id path. See Signed references.