Skip to content

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”
Terminal window
bin/rails generate ruact:scaffold Post title:string body:text published:boolean
bin/rails db:migrate

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


Terminal window
bin/dev

Open http://localhost:3000/posts. What you can do there, and what runs when you do:

  • Create a post. + New post opens PostForm. Submitting it calls createPost, a function generated from your routes that POSTs to PostsController#create. The action’s redirect_to post decides where you land; the component builds no URL.
  • Search. Typing in the box calls PostsQuery#search_posts in Ruby through GET /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. Delete opens PostDeleteDialog. Confirming calls destroyPost, 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.


The generated model is empty. Add a rule the Rails way:

app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true
end

Submit 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:

app/controllers/posts_controller.rb (generated)
def create
post = Post.new(post_params)
if post.save
redirect_to post
else
ruact_errors(post)
@ok = false
end
end

ruact_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:

app/javascript/components/LikeButton.tsx
"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:

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


Terminal window
bin/rails generate ruact:scaffold Comment author:string body:text
bin/rails db:migrate

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


Everything it generated is yours: edit it like any code you wrote. Some places to start:

  • Which columns the list shows — the rows hash in app/views/posts/index.html.erb, and the matching <th>/<td> in PostList.tsx. The view builds the rows explicitly, so a column you do not list never reaches the browser.
  • What the search matches — the columns list in app/queries/posts_query.rb.
  • Where a successful save lands — the redirect_to in create and update.
  • Deleting from the show page instead of the list — uncomment the redirect_to posts_url in destroy.

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.