docs/oss/building-features/mutations.md
React on Rails keeps mutations in Rails. React Server Components change how read-only UI can be rendered and streamed, but they do not move writes into the Node renderer. A React form, button, or TanStack Query mutation should submit to a Rails route, let the controller run the same authentication, authorization, strong parameters, ActiveRecord transactions, and validations that a server-rendered Rails form would use, then return JSON or navigate.
Do not add 'use server' to React on Rails application code. In React on Rails, the Node renderer
is a rendering process. It does not own Rails models, sessions, cookies, CSRF verification, or
transactions. Use a Client Component, a standard Rails form, or a TanStack Query mutation that calls a
Rails controller endpoint.
| Concern | Next.js Server Actions | TanStack Start server functions | React on Rails |
|---|---|---|---|
| Client call site | <form action={createPost}>, formAction, or an event handler that invokes a Server Function. | createServerFn({ method: 'POST' }), often called from a component, loader, hook, event handler, or TanStack Query mutation. | form_with, useRailsForm, createRailsAction, or a CSRF-aware fetch/TanStack Query mutation. |
| Where server logic runs | The Next.js app server, inside an async function marked with 'use server' or exported from a 'use server' file. | The TanStack Start server, inside the createServerFn handler. | The Rails controller action, backed by Rails models, policies, jobs, mailers, and transactions. |
| Request shape | Framework-owned POST request to the Server Action endpoint. | Framework-owned same-origin RPC request to the server function endpoint. | Ordinary Rails HTTP route, usually JSON for React islands or a normal HTML form post for full-page flows. |
| Security boundary | Verify authentication and authorization inside the action; Next.js applies same-origin Server Action protections. | Validate input in the server function; Start documents same-origin RPC and CSRF middleware for server functions. | Use Rails sessions, csrf_meta_tags, controller filters, strong parameters, and policy checks. React helpers attach CSRF headers for JSON requests. |
| Validation errors | Return serializable error state or throw/redirect according to the action pattern. | Return serializable error state, throw, redirect, or feed TanStack Query mutation state. | Return 422 JSON such as { "errors": { "name": ["can't be blank"] } }; useRailsForm maps this to field errors. |
| After the write | Call Next cache revalidation helpers, redirect, or return updated UI/data. | Invalidate TanStack Query cache, redirect, or update route state. | Let Rails redirect for full-page flows, return a safe redirect_to JSON hint, invalidate TanStack Query cache, or let the next Rails render stream fresh props. |
The practical translation is:
The same "create a project" mutation lands in a different server boundary in each stack. In Next.js, the form can call a Server Action; the database write, authorization, and cache refresh live in the Next.js server runtime:
// app/projects/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createProjectAction(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const status = String(formData.get('status') ?? 'draft');
if (!name) {
return { errors: { name: ["can't be blank"] } };
}
const project = await db.project.create({ data: { name, status } });
revalidatePath('/projects');
redirect(`/projects/${project.id}`);
}
// app/projects/form.tsx
import { createProjectAction } from './actions';
export function ProjectForm() {
return (
<form action={createProjectAction}>
<input name="name" />
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="active">Active</option>
</select>
<button type="submit">Create project</button>
</form>
);
}
In TanStack Start, the component can call a server function. The handler still runs in Start's server runtime, not in Rails:
// src/projects/projects.functions.ts
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
import { db } from '~/server/db';
const projectSchema = z.object({
name: z.string().min(1),
status: z.enum(['draft', 'active']),
});
export const createProject = createServerFn({ method: 'POST' })
.validator(projectSchema)
.handler(async ({ data }) => {
const project = await db.project.create({ data });
return { project };
});
// src/projects/ProjectForm.tsx
import { createProject } from './projects.functions';
await createProject({ data: { name: 'Apollo', status: 'draft' } });
In React on Rails, the Client Component calls an ordinary Rails route. The Rails controller, shown in the recipe below, owns authentication, authorization, strong parameters, transactions, validation errors, redirects, jobs, and cache expiry:
import { createRailsAction } from 'react-on-rails/railsAction';
type ProjectFormData = {
name: string;
status: 'draft' | 'active';
};
type ProjectResponse = {
project: { id: number; name: string; status: string };
};
const createProject = createRailsAction<{ project: ProjectFormData }, ProjectResponse>({
path: '/projects',
});
await createProject({ project: { name: 'Apollo', status: 'draft' } });
Use the narrowest path that matches the UI:
useRailsForm: best for React-controlled forms that need field errors,
processing, reset behavior, CSRF handling, and a small useForm-style API.createRailsAction with
TanStack Query: best for buttons, tables, optimistic updates, and typed mutation responses.fetch with React on Rails authenticity helpers: fine for one-off calls, but centralize
same-origin, JSON, and CSRF behavior once the pattern repeats.This is the Rails-native equivalent of a Next.js <form action={createPost}> Server Action or a
TanStack Start createServerFn({ method: 'POST' }) mutation: keep the server logic in Rails and make
the Client Component submit JSON to that route.
# config/routes.rb
resources :projects, only: [:new, :create, :show]
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
include ReactOnRails::Controller::FormResponders
def create
project = current_account.projects.build(project_params)
# Run your normal authorization here, for example `authorize project`.
if project.save
render json: {
project: { id: project.id, name: project.name, status: project.status },
redirect_to: project_path(project)
}, status: :created
else
render_model_errors(project)
end
end
private
def project_params
if params.key?(:project)
params.require(:project).permit(:name, :status)
else
params.permit(:name, :status)
end
end
end
'use client';
import type { FormEvent } from 'react';
import { useRailsForm } from 'react-on-rails/useRailsForm';
type ProjectFormData = {
name: string;
status: 'draft' | 'active';
};
export default function ProjectForm({ action }: { action: string }) {
const form = useRailsForm<ProjectFormData>({ name: '', status: 'draft' });
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void form
.post(action, {
onSuccess: ({ redirectTo }) => {
if (redirectTo) {
window.location.assign(redirectTo);
} else {
form.reset();
}
},
})
.catch(() => {
form.setError('base', 'Something went wrong. Please try again.');
});
};
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input value={form.data.name} onChange={(event) => form.setData('name', event.target.value)} />
</label>
{form.errors.name?.map((message) => (
<p className="error" key={message}>
{message}
</p>
))}
<label>
Status
<select
value={form.data.status}
onChange={(event) => form.setData('status', event.target.value as ProjectFormData['status'])}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
</select>
</label>
{form.errors.status?.map((message) => (
<p className="error" key={message}>
{message}
</p>
))}
{form.errors.base?.map((message) => (
<p className="error" key={message}>
{message}
</p>
))}
<button type="submit" disabled={form.processing}>
{form.processing ? 'Creating...' : 'Create project'}
</button>
</form>
);
}
base is not special to Rails; it is just a conventional field name for errors that are not attached
to one input. Use any key your app renders consistently.
For an ordinary React island:
<%= react_component("ProjectForm", props: { action: projects_path }) %>
For a React Server Components page, keep the form as a Client Component and pass ordinary serializable props from the Server Component:
// ProjectPage.tsx -- Server Component
import ProjectForm from './ProjectForm.client';
export default function ProjectPage({ projectsPath }: { projectsPath: string }) {
return <ProjectForm action={projectsPath} />;
}
The write still goes to ProjectsController#create. The Server Component can render the initial page
from Rails-provided props or async props, but the mutation belongs to Rails.
When the UI is a live table, command button, optimistic update, or cache-managed island, use TanStack Query for client state and keep the write endpoint in Rails.
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createRailsAction, RailsActionRequestError } from 'react-on-rails/railsAction';
type ProjectFormData = {
name: string;
status: 'draft' | 'active';
};
type ProjectResponse = {
project: { id: number; name: string; status: string };
};
type ValidationErrors = Record<string, string[]>;
type ErrorResponse = {
errors?: unknown;
};
const fallbackError: ValidationErrors = { base: ['Something went wrong. Please try again.'] };
const isValidationErrors = (errors: unknown): errors is ValidationErrors =>
Boolean(errors) &&
typeof errors === 'object' &&
!Array.isArray(errors) &&
Object.keys(errors).length > 0 &&
Object.values(errors).every(
(messages) => Array.isArray(messages) && messages.every((message) => typeof message === 'string'),
);
const createProject = createRailsAction<{ project: ProjectFormData }, ProjectResponse>({
path: '/projects',
});
export function useCreateProjectMutation(setValidationErrors: (errors: ValidationErrors) => void) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProject,
onSuccess: ({ project }) => {
queryClient.setQueryData(['project', String(project.id)], { project });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
onError: (error) => {
if (error instanceof RailsActionRequestError && error.response.status === 422) {
const responseBody = error.responseBody;
const errors =
responseBody && typeof responseBody === 'object' && 'errors' in responseBody
? (responseBody as ErrorResponse).errors
: undefined;
setValidationErrors(isValidationErrors(errors) ? errors : fallbackError);
return;
}
setValidationErrors(fallbackError);
},
});
}
This is close to a TanStack Start server-function call at the component boundary, but the server code
is still the Rails controller. If your app generates TypeScript response types from Rails, replace the
hand-written ProjectResponse with
RailsResponseType<'projects.create'>.
React on Rails RSC pages usually read data from Rails controller props or
stream_react_component_with_async_props.
After a mutation, choose the refresh mechanism that matches the UI:
redirect_to JSON hint
and navigate to it.For the migration-specific warning, see Mutations: Rails Controllers, Not Server Actions.
422 error JSON, authorization failure, and redirect
hint when used.useRailsForm