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