Skip to content

Full CRUD with Scaffold

This guide builds a complete CRUD application for a Post resource using ruact. By the end you will have index, show, new, create, edit, update, and destroy — all as React Server Components.


1. Generate the scaffold

bash
rails generate scaffold Post title:string body:text
rails db:migrate

2. Include the controller concern

Edit the generated controller or ApplicationController:

ruby
class ApplicationController < ActionController::Base
  include Ruact::Controller
end

3. Update the controller actions

Replace render with ruact_render in each action. Redirects stay as normal Rails redirects — ruact handles them automatically on the client side.

ruby
class PostsController < ApplicationController
  before_action :set_post, only: %i[show edit update destroy]

  def index
    @posts = Post.all.order(created_at: :desc)
    ruact_render
  end

  def show
    ruact_render
  end

  def new
    @post = Post.new
    ruact_render
  end

  def create
    @post = Post.new(post_params)
    if @post.save
      redirect_to @post, notice: "Post created."
    else
      ruact_render template: "posts/new"
    end
  end

  def edit
    ruact_render
  end

  def update
    if @post.update(post_params)
      redirect_to @post, notice: "Post updated."
    else
      ruact_render template: "posts/edit"
    end
  end

  def destroy
    @post.destroy
    redirect_to posts_path, notice: "Post deleted."
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :body)
  end
end

4. Write the ERB views

app/views/posts/index.html.erb

erb
<h1>Posts</h1>

<%= link_to "New Post", new_post_path %>

<% @posts.each do |post| %>
  <article>
    <h2><%= link_to post.title, post_path(post) %></h2>
    <p><%= post.body.truncate(100) %></p>
    <%= link_to "Edit", edit_post_path(post) %>
    <%= button_to "Delete", post_path(post), method: :delete,
        data: { confirm: "Are you sure?" } %>
  </article>
<% end %>

app/views/posts/show.html.erb

erb
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>

<%= link_to "Edit", edit_post_path(@post) %>
<%= link_to "Back", posts_path %>

app/views/posts/new.html.erb

erb
<h1>New Post</h1>
<%= render "form", post: @post %>
<%= link_to "Cancel", posts_path %>

app/views/posts/edit.html.erb

erb
<h1>Edit Post</h1>
<%= render "form", post: @post %>
<%= link_to "Cancel", post_path(@post) %>

app/views/posts/_form.html.erb

erb
<%= form_with model: post do |f| %>
  <% if post.errors.any? %>
    <div class="errors">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>
      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>

  <div>
    <%= f.label :body %>
    <%= f.text_area :body, rows: 6 %>
  </div>

  <%= f.submit %>
<% end %>

5. Add an interactive client component

Let's add a like button that only exists on the client:

app/javascript/components/LikeButton.jsx:

jsx
"use client"

import { useState } from "react"

export function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false)

  return (
    <button onClick={() => setLiked(!liked)} className={liked ? "liked" : ""}>
      {liked ? "❤️ Liked" : "🤍 Like"} post #{postId}
    </button>
  )
}

Use it in show.html.erb:

erb
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<LikeButton postId={@post.id} />
<%= link_to "Edit", edit_post_path(@post) %>

The like button state is local to the browser — no server round-trip needed.


6. Start the server and verify

bash
bin/dev

Navigate to http://localhost:3000/posts. You should see:

  • Index page loads — client-side navigation when you click links (no full reload)
  • New/edit forms submit via AJAX — redirect follows client-side
  • Delete button triggers a client-side navigation back to index
  • Like button state persists while browsing — resets on full page reload

Controlling which fields are serialized

By default, if you pass @post as a prop to a PascalCase component, ruact calls as_json on it. To control exactly which fields are exposed, use Ruact::Serializable:

ruby
class Post < ApplicationRecord
  include Ruact::Serializable

  ruact_props :id, :title, :body, :created_at
end

Now <PostCard post={@post} /> sends only id, title, body, and created_at to the browser — not updated_at, internal flags, or any other columns. ruact_props works directly on an ActiveRecord model: a typo in a prop name still fails loudly (for an AR model at the first serialize, since its readers are defined lazily).

See the Ruact::Serializable API reference for more options.

Released under the MIT License.