About inertia-rails-forms
A skill guide for building Inertia.js and Rails forms across React, Vue, and Svelte. It explains when to use the Form component or useForm and covers validation, uploads, and common pitfalls.
Key features
- 01Choosing Form or useForm
- 02Rails validation error handling
- 03File uploads and progress
- 04Framework-specific guidance
Use cases
- 01Building simple create and edit forms
- 02Implementing multi-step or dynamic forms
- 03Adding file uploads with progress
- 04Diagnosing form state and submission issues
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ inertia-rails/ skills --skill inertia-rails-formsAfter installing, type/inertia-rails-formsin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/advanced-forms.mdfile-uploads.mdsvelte.mdvue.mdDetails
From the project READMEInertia Rails Forms
Full-stack form handling for Inertia.js + Rails.
Before building a form, ask:
- Simple create/edit? →
<Form>component (no state management needed) - Requires per-field UI elements? → Still
<Form>. ReactuseStatefor UI state (preview URL, file size display) is independent of form data —<Form>handles the submission;useStatehandles the UI. - Multi-step wizard, dynamic fields (add/remove inputs), or form data
shared with sibling components (e.g., live preview panel)? →
useFormhook - Tempted by react-hook-form? → Don't. Inertia's
<Form>already handles CSRF tokens, redirect following, error mapping from Rails, processing state, file upload detection, and history state. react-hook-form would duplicate or fight all of this.
When NOT to use <Form> or useForm:
- Data lookups — not a form submission. Use
router.getwith debounce +preserveState, or rawfetchfor large datasets - Inline single-field edits without navigation –
router.patchdirectly, oruseFormif you need error display on the field
NEVER:
- Use
react-hook-form,vee-validate, orsveltekit-superforms— Inertia<Form>already handles CSRF, redirect following, error mapping, processing state, and file detection. These libraries fight Inertia's request lifecycle. - Pass
data={...}to<Form>— it has nodataprop. Data comes from inputnameattributes automatically.datais auseFormconcept. - Use
useFormfor simple create/edit —<Form>handles these without state management. ReserveuseFormfor multi-step wizards, dynamic add/remove fields, or form data shared with sibling components. - Use controlled
value=instead ofdefaultValueon inputs — controlled inputs bypass<Form>'s dirty tracking, makingisDirtyalwaysfalse. - Omit
value="1"on checkboxes — without it, the browser submits"on"and Rails won't cast to boolean correctly. - Call
useForminside a loop or conditional — it's a hook (React rules apply). Create one form instance per logical form.
<Form> Component (Preferred)
The simplest way to handle forms. Collects data from input name attributes
automatically — no manual state management needed. <Form> has NO data prop —
do NOT pass data={...} (that's a useForm concept). For edit forms, use
defaultValue on inputs.
Use render function children {({ errors, processing }) => (...)} to
access form state. Plain children work but give no access to errors,
processing, or progress.
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form method="post" action="/users">
{({ errors, processing }) => (
<>
<input type="text" name="name" />
{errors.name && <span className="error">{errors.name}</span>}
<input type="email" name="email" />
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
</>
)}
</Form>
)
}
// Plain children — valid but no access to errors/processing/progress:
// <Form method="post" action="/users">
// <input name="name" />
// <button type="submit">Create</button>
// </Form>
Delete Form
<Form method="delete" action={`/posts/${post.id}`}>
{({ processing }) => (
<button type="submit" disabled={processing}>
{processing ? 'Deleting...' : 'Delete Post'}
</button>
)}
</Form>
Key Render Function Properties
| Property | Type | Purpose |
|---|---|---|
errors | Record<string, string> | Validation errors keyed by field name |
processing | boolean | True while request is in flight |
progress | { percentage: number } | null | Upload progress (file uploads only) |
hasErrors | boolean | True if any errors exist |
wasSuccessful | boolean | True after last submit succeeded |
recentlySuccessful | boolean | True for 2s after success — ideal for "Saved!" feedback |
isDirty | boolean | True if any input changed from initial value |
reset | (...fields) => void | Reset specific fields or all fields |
clearErrors | (...fields) => void | Clear specific errors or all errors |
Additional <Form> props (errorBag, only, resetOnSuccess,
event callbacks like onBefore, onSuccess, onError, onProgress) are
documented in references/advanced-forms.md — see loading trigger below.
Edit Form (Pre-populated)
Use method="patch" and uncontrolled defaults:
- Text/textarea →
defaultValue - Checkbox/radio →
defaultChecked - Select →
defaultValueon<select>
Checkbox without explicit
valuesubmits"on"— setvalue="1"so Rails casts to boolean correctly.
<Form method="patch" action={`/posts/${post.id}`}>
{({ errors, processing }) => (
<>
<input type="text" name="title" defaultValue={post.title} />
{errors.title && <span className="error">{errors.title}</span>}
<label>
<input type="checkbox" name="published" value="1"
defaultChecked={post.published} />
Published
</label>
<button type="submit" disabled={processing}>
{processing ? 'Saving...' : 'Update Post'}
</button>
</>
)}
</Form>
Transforming Data
Use the transform prop to reshape data before submission without useForm.
For advanced transform with useForm, see references/advanced-forms.md.
External Access with formRef
The ref exposes the same methods and state as render function props (FormComponentSlotProps).
Use when you need to interact with the form from outside <Form>.
Key ref methods: submit(), reset(), clearErrors(), setError(),
getData(), getFormData(), validate(), touch(), defaults().
State: errors, processing, progress, isDirty, hasErrors,
wasSuccessful, recentlySuccessful.
import {useRef} from 'react'
import {Form} from '@inertiajs/react'
import type {FormComponentRef} from '@inertiajs/core'
export default function CreateUser() {
const formRef = useRef<FormComponentRef>(null)
return (
<>
<Form ref={formRef} method='post' action='/users'>
{({errors}) => (
<>
<input type='text' name='name'/>
{errors.name && <span className='error'>{errors.name}</span>}
</>
)}
</Form>
<button onClick={() => formRef.current?.submit()}>Submit</button>
<button onClick={() => formRef.current?.reset()}>Reset</button>
</>
)
}
useForm Hook
Use useForm only for multi-step wizards, dynamic add/remove fields, or
form data shared with sibling components.
MANDATORY — READ ENTIRE FILE when using useForm hook, transform,
errorBag, resetOnSuccess, multi-step forms, or client-side validation
with setError:
references/advanced-forms.md (~330 lines) — full
useForm API, transform examples, error bag scoping, multi-step wizard
patterns, and client-side validation.
Do NOT load advanced-forms.md when using <Form> component for simple
create/edit forms — the examples above are sufficient.
File Uploads
Both <Form> and useForm auto-detect files and switch to FormData.
Upload progress is built into the render function — destructure progress
alongside errors and processing:
type Props = { user: User }
export default function EditProfile({ user }: Props) {
return (
<Form method="patch" action="/profile">
{({ errors, processing, progress }) => (
<>
<input type="text" name="name" defaultValue={user.name} />
{errors.name && <span className="error">{errors.name}</span>}
<input type="file" name="avatar" />
{errors.avatar && <span className="error">{errors.avatar}</span>}
{progress && (
<progress value={progress.percentage ?? 0} max="100" />
)}
<button type="submit" disabled={processing}>
{processing ? 'Uploading...' : 'Save'}
</button>
</>
)}
</Form>
)
}
Choosing <Form> vs useForm for uploads:
- File submits with other fields (avatar + name, one Save button) → file input inside
<Form> - Standalone immediate upload (uploads on select, no Save button) →
<Form>+formRef.submit()on change - Drag-and-drop upload →
useForm(dropped files aren't in DOM inputs,setDatais cleaner)
Preview / validation → useState alongside either approach, see
references/file-uploads.md.
Vue / Svelte
All examples above use React syntax. For Vue 3 or Svelte equivalents:
- Vue 3:
references/vue.md—<Form>with#defaultscoped slot,useFormreturns reactive proxy (form.emailnotsetData),v-modelbinding - Svelte:
references/svelte.md—<Form>with{#snippet}syntax,useFormreturns Writable store ($form.email),bind:value, ref exposes methods only (not reactive state)
MANDATORY — READ THE MATCHING FILE when the project uses Vue or Svelte.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
No access to errors/processing | Plain children instead of render function | <Form> children should be {({ errors, processing }) => (...)} |
| Form sends GET instead of POST | Missing method prop | Add method="post" (or "patch", "delete") |
| File upload sends empty body | PUT/PATCH with file | Multipart limitation — Inertia auto-adds _method field to convert to POST |
| Errors don't clear after fixing field | Stale error state | Errors auto-clear on next submit; use clearErrors('field') for immediate clearing |
isDirty always false | Using value instead of defaultValue | Controlled inputs (value=) bypass dirty tracking — use defaultValue |
progress is always null | No file input in form | Progress tracking only activates when <Form> detects a file input |
Checkbox sends "on" | No explicit value | Add value="1" to checkbox inputs |
| Form submits twice in dev | React StrictMode double-invocation | Normal in development — StrictMode remounts components. Only fires once in production |
Used useForm for file upload with preview | onChange + useState mistaken for "programmatic data manipulation" | <Form> + useState for preview UI. useForm is only needed when form submission data must live in React state (multi-step, dynamic add/remove fields). File preview is local UI state, not form data |
Related Skills
- Server-side PRG & errors →
inertia-rails-controllers(redirect_back, to_hash, flash) - shadcn inputs →
shadcn-inertia(Input/Select adaptation, toast UI) - Page props typing →
inertia-rails-typescript(type Propsnotinterface, TS2344)
MANDATORY — READ ENTIRE FILE when handling file uploads with image preview,
Active Storage, or direct uploads:
references/file-uploads.md (~200 lines) — image preview
with <Form>, Active Storage integration, direct upload setup, multiple files,
and progress tracking.