Skip to content

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.

bash
rails generate ruact:scaffold Post title:string body:text published:boolean

This produces:

FileWhat it is
app/controllers/posts_controller.rbThe 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.rbresources :posts (injected idempotently).
app/javascript/components/PostList.tsxA 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.tsxA 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.tsxA 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.rbThe read side: PostsQuery < ApplicationQuery with search(q:), backing the List's search box.
app/queries/application_query.rbThe query base (< Ruact::Query), created once and never clobbered on re-scaffold.
config/routes.rbresources :posts and ruact_queries PostsQuery (both injected idempotently).
app/views/posts/{index,show,new,edit}.html.erbRender the components from controller ivars (no as_json in the view).
spec/requests/posts_spec.rbA light controller smoke spec.

Prerequisite: run rails generate ruact:install first — it scaffolds app/javascript/.ruact/ and primes the route-driven codegen that emits the createPost/updatePost/destroyPost accessors the components import. The model is not generated (it stays orthogonal to your schema); run rails generate model Post … + rails db:migrate as 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:

sh
rails generate ruact:scaffold Post title:string body:text published:boolean --shadcn

With --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 shadcn Table (still dep-free — a generated client-side sort, no @tanstack/react-table), with the per-row actions collapsing into a responsive DropdownMenu under the md breakpoint.
  • PostForm → shadcn form controls mapped per attribute type (Input / Textarea / Switch / Select + Label + Button).
  • PostDeleteDialog → a controlled shadcn AlertDialog.

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:

sh
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 setupshadcn/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 yourselfYou 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.

<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 typeCell
string / textplain text
booleanplain "Yes" / "No" text
integer / float / decimal / referencesnumeric
date / datetimelocale-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.rbclass PostsQuery < ApplicationQuery with a public search(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 blank q returns the whole collection.
  • app/queries/application_query.rbclass ApplicationQuery < Ruact::Query, the host base every query inherits. ruact:install does not ship it, so the first scaffold creates it; a second scaffold never clobbers a customized base.
  • config/routes.rbruact_queries PostsQuery is injected (idempotently), which mounts search as GET /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:

ruby
# 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 nametitleto_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.

bash
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.

typeTS wire typeForm control (native)
stringstring<input type="text">
textstring<textarea>
integernumber<input type="number">
floatnumber<input type="number">
decimalnumber<input type="number">
booleanboolean<input type="checkbox">
datestring<input type="date">
datetimestring<input type="datetime-local">
referencesnumber<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). #destroy returns { ok: true } (no redirect). onConfirm calls destroy<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>_url line in #destroy; the destroy<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.

Released under the MIT License.