Inertia 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.
tsximport { 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
tsx<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.
tsx<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.
tsximport {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:
tsxtype 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.

