Build forms in Inertia Rails applications with proper validation, file uploads, and error handling...
Comprehensive guide to building forms in Inertia Rails applications with React, Vue, or Svelte.
The useForm helper provides reactive form state management with built-in features for validation, file uploads, and submission handling.
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>
)
}
<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>
<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>
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
| 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 |
| 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 |
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: () => {},
})
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))}
/>
<template>
<div v-if="form.progress">
<progress :value="form.progress.percentage" max="100" />
<span>{{ form.progress.percentage }}%</span>
</div>
</template>
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
})
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" />
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
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')
For simpler forms, use the Form component:
<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>
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>
)
}
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>
)
}
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>
)
}
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
Preserve form data across browser history navigation using useRemember.
import { useRemember } from '@inertiajs/vue3'
// Form state persists across back/forward navigation
const form = useRemember({
name: '',
email: '',
message: '',
})
Provide a unique key when multiple components use remember:
// Contact form
const contactForm = useRemember({
email: '',
message: '',
}, 'ContactForm')
// Newsletter form
const newsletterForm = useRemember({
email: '',
}, 'NewsletterForm')
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,
})
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
}
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>
)
}
Make standalone HTTP requests without triggering Inertia page visits. Useful for API calls, background operations, and AJAX requests that don't need navigation.
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>
)
}
| 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.
Update the UI immediately before the server responds, with automatic rollback on failure:
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`)
}
<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>
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.
# 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
# Only include field errors, not full model
redirect_to new_user_url, inertia: { errors: user.errors.to_hash }
// 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
}
}
form.post('/users', {
preserveScroll: 'errors', // Only preserve on validation errors
})
form.post('/users', {
onSuccess: () => form.reset('password', 'password_confirmation'),
})
<button type="submit" :disabled="form.processing">
<span v-if="form.processing">
<Spinner /> Saving...
</span>
<span v-else>Save</span>
</button>
function deleteUser() {
if (confirm('Are you sure?')) {
router.delete(`/users/${user.id}`)
}
}