docs / guides
Full CRUD with Scaffold
Generate a complete CRUD resource with a React interface using rails generate ruact:scaffold, then make it yours.
This guide takes one command — rails generate ruact:scaffold — from an empty resource to a working CRUD screen, then walks through what it wrote and how to change it. Every option and every generated file is listed in the ruact:scaffold reference; this page is the tour.
Before you start: an app with ruact installed. If you followed Getting Started, you have one.
Step 1: Generate the resource
Section titled “1. Generate the resource”bin/rails generate ruact:scaffold Post title:string body:text published:booleanbin/rails db:migrateThe first command hands the model, the migration, the resources :posts route and your test framework’s stubs to Rails’ own resource generator, then writes the ruact layer on top:
| File | What it is |
|---|---|
app/controllers/posts_controller.rb |
The seven RESTful actions, with include Ruact::Controller and include Ruact::Server |
app/views/posts/{index,show,new,edit}.html.erb |
ERB views that render the components |
app/javascript/components/PostList.tsx |
A table with sorting, a search box and per-row actions |
app/javascript/components/PostForm.tsx |
One form for new and edit, with inline errors |
app/javascript/components/PostDeleteDialog.tsx |
The delete confirmation |
app/queries/posts_query.rb |
PostsQuery#search_posts, the read behind the search box |
It does not run migrations — that is the second line.
Step 2: Use it
Section titled “2. Use it”bin/devOpen http://localhost:3000/posts. What you can do there, and what runs when you do:
- Create a post.
+ New postopensPostForm. Submitting it callscreatePost, a function generated from your routes thatPOSTs toPostsController#create. The action’sredirect_to postdecides where you land; the component builds no URL. - Search. Typing in the box calls
PostsQuery#search_postsin Ruby throughGET /q/searchPosts, and the table swaps in the results. Clearing the box shows the server-rendered rows again. - Sort. Every column header sorts the rows on the client.
- Delete.
DeleteopensPostDeleteDialog. Confirming callsdestroyPost, and the row disappears from the table without a reload. - Navigate. Links between the list, a post and its form are client-side navigations: no full page reload.
The components are plain HTML elements — a <table>, <input>s, a native <dialog> — with no CSS of their own, so they look like whatever your app’s stylesheet makes them. For shadcn/ui components instead, see Using shadcn/ui.
Step 3: Add a validation
Section titled “3. Add a validation”The generated model is empty. Add a rule the Rails way:
class Post < ApplicationRecord validates :title, presence: trueendSubmit the form with no title: “Title can’t be blank” appears under the Title field, and you stay on the form. Nothing on the React side changed. The generated action hands the record’s errors to the form:
def create post = Post.new(post_params) if post.save redirect_to post else ruact_errors(post) @ok = false endendruact_errors(post) puts post.errors in the response, keyed by attribute, and PostForm shows each message under its own field. update works the same way.
Step 4: Put your own component on a page
Section titled “4. Put your own component on a page”show.html.erb is an ordinary Rails view — no component in it. Add one. Create the component:
"use client"
import { useState } from "react"
export function LikeButton({ likes }: { likes: number }) { const [liked, setLiked] = useState(false)
return ( <button onClick={() => setLiked(!liked)}> {liked ? `❤️ ${likes + 1}` : `🤍 ${likes}`} </button> )}Then use it in the view, anywhere among the HTML:
<LikeButton likes={12} />Reload a post’s page and click it. The button’s state lives in the browser, so it resets on a full reload. Props are attributes, and {…} holds Ruby, so likes={@post.id} would pass the post’s id. Content between a component’s tags reaches it as children: <LikeButton>Like</LikeButton> passes the text Like, which LikeButton ignores because it never renders children. See Children.
Step 5: A second resource
Section titled “5. A second resource”bin/rails generate ruact:scaffold Comment author:string body:textbin/rails db:migrateEach resource gets its own search query, named after it — CommentsQuery#search_comments at GET /q/searchComments — because query names share one namespace across the app: one route and one generated function per name. If two query classes define the same method name, the routes stop loading with an error that names the query and the fix.
Changing what the scaffold wrote
Section titled “Changing what the scaffold wrote”Everything it generated is yours: edit it like any code you wrote. Some places to start:
- Which columns the list shows — the
rowshash inapp/views/posts/index.html.erb, and the matching<th>/<td>inPostList.tsx. The view builds the rows explicitly, so a column you do not list never reaches the browser. - What the search matches — the
columnslist inapp/queries/posts_query.rb. - Where a successful save lands — the
redirect_toincreateandupdate. - Deleting from the show page instead of the list — uncomment the
redirect_to posts_urlindestroy.
When you pass a model to a component yourself rather than a hash, ruact serializes it with as_json. To control which fields cross to the browser, declare them on the model with ruact_props — see Ruact::Serializable.