Inertia Rails Forms
Comprehensive guide to building forms in Inertia Rails applications with React, Vue, or Svelte.
The useForm Helper
The useForm helper provides reactive form state management with built-in features for validation, file uploads, and submission handling.
React
import { useForm } from '@inertiajs/react'
export default function CreateUser() {
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
password: '',
avatar: null,
})
function submit(e) {
e.preventDefault()
post('/users', {
onSuccess: () => reset('password'),
preserveScroll: true,
})
}
return (
<form onSubmit={submit}>
<div>
<label>Name</label>
<input
type="text"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label>Email</label>
<input
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label>Password</label>
<input
type="password"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<div>
<label>Avatar</label>
<input
type="file"
onChange={(e) => setData('avatar', e.target.files[0])}
/>
</div>
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
</form>
)
}
Vue 3
<script setup>
import { useForm } from '@inertiajs/vue3'
const form = useForm({
name: '',
email: '',
password: '',
avatar: null,
})
function submit() {
form.post('/users', {
onSuccess: () => form.reset('password'),
preserveScroll: true,
})
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label>Name</label>
<input v-model="form.name" type="text" />
<span v-if="form.errors.name" class="error">{{ form.errors.name }}</span>
</div>
<div>
<label>Email</label>
<input v-model="form.email" type="email" />
<span v-if="form.errors.email" class="error">{{ form.errors.email }}</span>
</div>
<div>
<label>Password</label>
<input v-model="form.password" type="password" />
<span v-if="form.errors.password" class="error">{{ form.errors.password }}</span>
</div>
<div>
<label>Avatar</label>
<input type="file" @change="form.avatar = $event.target.files[0]" />
<progress v-if="form.progress" :value="form.progress.percentage" max="100" />
</div>
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Create User' }}
</button>
</form>
</template>
Svelte
<script>
import { useForm } from '@inertiajs/svelte'
const form = useForm({
name: '',
email: '',
password: '',
avatar: null,
})
function submit() {
form.post('/users', {
onSuccess: () => form.reset('password'),
preserveScroll: true,
})
}
</script>
<form onsubmit={(e) => { e.preventDefault(); submit() }}>
<div>
<label>Name</label>
<input type="text" bind:value={form.name} />
{#if form.errors.name}
<span class="error">{form.errors.name}</span>
{/if}
</div>
<div>
<label>Email</label>
<input type="email" bind:value={form.email} />
{#if form.errors.email}
<span class="error">{form.errors.email}</span>
{/if}
</div>
<div>
<label>Password</label>
<input type="password" bind:value={form.password} />
</div>
<div>
<label>Avatar</label>
<input type="file" onchange={(e) => (form.avatar = e.target.files[0])} />
</div>
<button type="submit" disabled={form.processing}>
{form.processing ? 'Creating...' : 'Create User'}
</button>
</form>
Rails Controller Pattern
The standard pattern for handling form submissions:
class UsersController < ApplicationController
def new
render inertia: {}
end
def create
user = User.new(user_params)
if user.save
redirect_to users_url, notice: 'User created successfully!'
else
redirect_to new_user_url, inertia: { errors: user.errors }
end
end
def edit
user = User.find(params[:id])
render inertia: { user: user.as_json(only: [:id, :name, :email]) }
end
def update
user = User.find(params[:id])
if user.update(user_params)
redirect_to user_url(user), notice: 'User updated successfully!'
else
redirect_to edit_user_url(user), inertia: { errors: user.errors }
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :avatar)
end
end
useForm Properties and Methods
Properties
| Property | Type | Description |
|----------|------|-------------|
| data | Object | Current form data |
| errors | Object | Validation errors from server |
| hasErrors | Boolean | Whether errors exist |
| processing | Boolean | Whether form is submitting |
| progress | Object | File upload progress |
| wasSuccessful | Boolean | True after successful submission |
| recentlySuccessful | Boolean | True for 2 seconds after success |
| isDirty | Boolean | Whether form data has changed |
Methods
| Method | Description |
|--------|-------------|
| setData(key, value) | Set a single field value |
| setData(values) | Set multiple field values |
| reset() | Reset all fields to initial values |
| reset(...fields) | Reset specific fields |
| clearErrors() | Clear all validation errors |
| clearErrors(...fields) | Clear specific field errors |
| setError(field, message) | Set a custom error |
| setError(errors) | Set multiple errors |
| transform(callback) | Transform data before submission |
| defaults() | Update default values for reset |
| get(url, options) | Submit GET request |
| post(url, options) | Submit POST request |
| put(url, options) | Submit PUT request |
| patch(url, options) | Submit PATCH request |
| delete(url, options) | Submit DELETE request |
| cancel() | Cancel an in-progress form submission |
Submission Options
form.post('/users', {
// Preserve component state on validation errors
preserveState: true, // or 'errors' to preserve only on errors
// Preserve scroll position
preserveScroll: true, // or 'errors' to preserve only on errors
// Custom headers
headers: { 'X-Custom': 'value' },
// Force FormData even without files
forceFormData: true,
// Error bag for multiple forms on same page
errorBag: 'createUser',
// Event callbacks
onBefore: (visit) => confirm('Submit form?'),
onStart: (visit) => {},
onProgress: (progress) => {},
onSuccess: (page) => form.reset(),
onError: (errors) => console.log(errors),
onCancel: () => {},
onFinish: () => {},
})
File Uploads
Inertia automatically converts forms with files to FormData:
const form = useForm({
name: '',
avatar: null,
documents: [], // Multiple files
})
// Single file
<input type="file" onChange={(e) => setData('avatar', e.target.files[0])} />
// Multiple files
<input
type="file"
multiple
onChange={(e) => setData('documents', Array.from(e.target.files))}
/>
Upload Progress
<template>
<div v-if="form.progress">
<progress :value="form.progress.percentage" max="100" />
<span>{{ form.progress.percentage }}%</span>
</div>
</template>
File Uploads with PUT/PATCH
Some servers don't support multipart PUT/PATCH. Use method spoofing:
// Instead of form.put()
form.post(`/users/${user.id}`, {
_method: 'put', // Rails recognizes this
})
Nested Data
Use bracket notation for nested attributes:
const form = useForm({
user: {
name: '',
profile: {
bio: '',
},
},
})
// Access errors
form.errors['user.name']
form.errors['user.profile.bio']
Or with Rails-style params:
<input name="user[name]" v-model="form.user.name" />
<input name="user[profile][bio]" v-model="form.user.profile.bio" />
Multiple Forms on Same Page
Use error bags to isolate validation errors:
// Login form
const loginForm = useForm({ email: '', password: '' })
loginForm.post('/login', { errorBag: 'login' })
// Register form
const registerForm = useForm({ name: '', email: '', password: '' })
registerForm.post('/register', { errorBag: 'register' })
Server-side:
def create
# ...
redirect_to root_url, inertia: {
errors: { login: { email: 'Invalid credentials' } }
}
end
Access errors: page.props.errors.login.email
Form Transforms
Transform data before submission:
const form = useForm({
first_name: 'John',
last_name: 'Doe',
})
form
.transform((data) => ({
...data,
full_name: `${data.first_name} ${data.last_name}`,
}))
.post('/users')
The Form Component (Declarative)
For simpler forms, use the Form component:
Vue
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form action="/users" method="post" v-slot="{ errors, processing }">
<input type="text" name="name" />
<span v-if="errors.name">{{ errors.name }}</span>
<input type="email" name="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<button type="submit" :disabled="processing">
Submit
</button>
</Form>
</template>
React
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form action="/users" method="post">
{({ errors, processing }) => (
<>
<input type="text" name="name" />
{errors.name && <span>{errors.name}</span>}
<input type="email" name="email" />
{errors.email && <span>{errors.email}</span>}
<button type="submit" disabled={processing}>
Submit
</button>
</>
)}
</Form>
)
}
Form Component Advanced Features (v3)
The Form component in v3 supports dotted notation for nested data and arrays:
import { Form } from '@inertiajs/react'
export default function CreateReport() {
return (
<Form action="/reports" method="post">
{({ errors, processing, isDirty, wasSuccessful }) => (
<>
{/* Dotted notation for nested data */}
<input type="text" name="report.title" />
{errors['report.title'] && <span>{errors['report.title']}</span>}
<input type="text" name="report.metadata.author" />
{/* Array support */}
<input type="text" name="tags[]" />
<input type="text" name="tags[]" />
{/* Disable inputs while processing */}
<button type="submit" disabled={processing}>
Submit
</button>
</>
)}
</Form>
)
}
useFormContext Hook
Access form state from deeply nested components without prop drilling:
import { useFormContext } from '@inertiajs/react'
function SubmitButton() {
const { processing, isDirty } = useFormContext()
return (
<button type="submit" disabled={processing || !isDirty}>
{processing ? 'Saving...' : 'Save'}
</button>
)
}
Precognition (Real-Time Validation)
Validate forms server-side in real-time without duplicating validation rules on the client:
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form action="/users" method="post" precognition>
{({ errors, valid, invalid, validate }) => (
<>
<input
type="email"
name="email"
onBlur={() => validate('email')}
/>
{valid('email') && <span className="text-green-500">β</span>}
{invalid('email') && <span className="text-red-500">{errors.email}</span>}
<button type="submit">Create</button>
</>
)}
</Form>
)
}
Server-side:
# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
config.precognition_prevent_writes = true # Prevent DB writes during validation
end
Remembering Form State
Preserve form data across browser history navigation using useRemember.
The useRemember Hook
import { useRemember } from '@inertiajs/vue3'
// Form state persists across back/forward navigation
const form = useRemember({
name: '',
email: '',
message: '',
})
Multiple Components on Same Page
Provide a unique key when multiple components use remember:
// Contact form
const contactForm = useRemember({
email: '',
message: '',
}, 'ContactForm')
// Newsletter form
const newsletterForm = useRemember({
email: '',
}, 'NewsletterForm')
With useForm Helper
The form helper has built-in remember support:
// Pass a unique key as first argument
const form = useForm('CreateUser', {
name: '',
email: '',
password: '',
})
// For edit forms, include the ID for uniqueness
const form = useForm(`EditUser:${props.user.id}`, {
name: props.user.name,
email: props.user.email,
})
Manual State Management
import { router } from '@inertiajs/vue3'
// Save state manually
router.remember({ step: 2, selections: ['a', 'b'] }, 'wizard-state')
// Restore state
const savedState = router.restore('wizard-state')
if (savedState) {
// Restore component state from savedState
}
React Example
import { useRemember } from '@inertiajs/react'
export default function ContactForm() {
const [form, setForm] = useRemember({
name: '',
email: '',
message: '',
}, 'ContactForm')
return (
<form>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
{/* ... */}
</form>
)
}
The useHttp Hook (v3)
Make standalone HTTP requests without triggering Inertia page visits. Useful for API calls, background operations, and AJAX requests that don't need navigation.
React
import { useHttp } from '@inertiajs/react'
export default function SearchUsers() {
const { data, post, processing, errors } = useHttp()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function search() {
post('/api/search', {
data: { query },
onSuccess: (response) => setResults(response.data),
})
}
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={search} disabled={processing}>
{processing ? 'Searching...' : 'Search'}
</button>
{results.map(user => <div key={user.id}>{user.name}</div>)}
</div>
)
}
Key Differences from useForm
| Feature | useForm | useHttp | |---------|---------|---------| | Triggers navigation | Yes | No | | Returns promises | No | Yes | | Updates page props | Yes | No | | Reactive state | Yes | Yes | | File upload progress | Yes | Yes | | Precognition support | Yes | Yes |
Use useHttp when you need to make API requests that shouldn't cause a page transition.
Optimistic Updates (v3)
Update the UI immediately before the server responds, with automatic rollback on failure:
With router
import { router } from '@inertiajs/react'
function toggleLike(post) {
router
.optimistic((page) => {
// Optimistically update the page props
const postIndex = page.props.posts.findIndex(p => p.id === post.id)
page.props.posts[postIndex].liked = !post.liked
page.props.posts[postIndex].likes_count += post.liked ? -1 : 1
})
.post(`/posts/${post.id}/toggle-like`)
}
With Form component
<Form
action={`/posts/${post.id}/toggle-like`}
method="post"
optimistic={(page) => {
const p = page.props.posts.find(p => p.id === post.id)
p.liked = !p.liked
}}
>
<button type="submit">
{post.liked ? 'β€οΈ' : 'π€'} {post.likes_count}
</button>
</Form>
With useForm
const form = useForm({})
form
.optimistic((page) => {
page.props.posts[0].title = 'Updated title'
})
.patch(`/posts/${post.id}`)
Optimistic updates automatically roll back if the server returns validation errors or a non-2xx response.
Best Practices
1. Always Use the PRG Pattern
# Incorrect - renders on POST
def create
@user = User.create(user_params)
render inertia: { user: @user }
end
# Correct - redirect after action
def create
user = User.create(user_params)
redirect_to user_url(user)
end
2. Return Minimal Error Data
# Only include field errors, not full model
redirect_to new_user_url, inertia: { errors: user.errors.to_hash }
3. Handle Validation on Client and Server
// Client-side for UX
const validateEmail = (email) => {
if (!email.includes('@')) {
form.setError('email', 'Invalid email format')
return false
}
return true
}
function submit() {
if (validateEmail(form.email)) {
form.post('/users') // Server validates too
}
}
4. Preserve Scroll on Errors
form.post('/users', {
preserveScroll: 'errors', // Only preserve on validation errors
})
5. Reset Sensitive Fields on Success
form.post('/users', {
onSuccess: () => form.reset('password', 'password_confirmation'),
})
6. Show Loading State
<button type="submit" :disabled="form.processing">
<span v-if="form.processing">
<Spinner /> Saving...
</span>
<span v-else>Save</span>
</button>
7. Confirm Destructive Actions
function deleteUser() {
if (confirm('Are you sure?')) {
router.delete(`/users/${user.id}`)
}
}