Forms with Inertia 2.0 + React
Forms are where most web apps get messy. State management, validation, error handling, file uploads, loading states, there's a lot to juggle.
Inertia's useForm hook handles most of this for you. But there's a difference between using it and using it well.
I've built dozens of forms in LeadSprout, from simple contact forms to multi-step onboarding flows with file uploads and conditional logic. Here's everything I've learnt about building forms that actually work in production.
The useForm Hook
Let's start with the basics. The useForm hook gives you everything you need:
import { useForm } from '@inertiajs/react'
export default function CreateLead() {
const { data, setData, post, processing, errors, reset } = useForm({
company_name: '',
email: '',
website: '',
notes: '',
})
function submit(e: React.FormEvent) {
e.preventDefault()
post('/leads')
}
return (
<form onSubmit={submit}>
<div>
<label htmlFor="company_name">Company Name</label>
<input
id="company_name"
type="text"
value={data.company_name}
onChange={e => setData('company_name', e.target.value)}
/>
{errors.company_name && (
<p className="text-red-500 text-sm">{errors.company_name}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && (
<p className="text-red-500 text-sm">{errors.email}</p>
)}
</div>
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create Lead'}
</button>
</form>
)
}
What you get out of the box:
data- your form statesetData- update individual fieldspost,put,patch,delete- submit methodsprocessing- true whilst submittingerrors- validation errors from Laravelreset- reset to initial values
TypeScript with useForm
Always type your forms:
interface LeadForm {
company_name: string
email: string
website: string
notes: string
}
const { data, setData, post, errors } = useForm<LeadForm>({
company_name: '',
email: '',
website: '',
notes: '',
})
// Now TypeScript knows:
// - setData only accepts valid field names
// - data.company_name is a string
// - errors can have company_name, email, website, or notes
For forms with files:
interface ProfileForm {
name: string
email: string
avatar: File | null
}
const form = useForm<ProfileForm>({
name: user.name,
email: user.email,
avatar: null,
})
Handling Different Input Types
Text Inputs
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
Checkboxes
<input
type="checkbox"
checked={data.subscribe}
onChange={e => setData('subscribe', e.target.checked)}
/>
Select Dropdowns
<select
value={data.country}
onChange={e => setData('country', e.target.value)}
>
<option value="">Select country</option>
<option value="uk">United Kingdom</option>
<option value="us">United States</option>
<option value="ro">Romania</option>
</select>
Radio Buttons
{['small', 'medium', 'large'].map(size => (
<label key={size}>
<input
type="radio"
name="size"
value={size}
checked={data.size === size}
onChange={e => setData('size', e.target.value)}
/>
{size}
</label>
))}
Textareas
<textarea
value={data.description}
onChange={e => setData('description', e.target.value)}
rows={4}
/>
File Uploads
Inertia handles file uploads automatically. When your form data includes a File, it converts everything to FormData behind the scenes.
interface UploadForm {
title: string
document: File | null
}
export default function UploadDocument() {
const { data, setData, post, processing, progress, errors } = useForm<UploadForm>({
title: '',
document: null,
})
function submit(e: React.FormEvent) {
e.preventDefault()
post('/documents')
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.title}
onChange={e => setData('title', e.target.value)}
/>
<input
type="file"
onChange={e => setData('document', e.target.files?.[0] ?? null)}
/>
{/* Progress bar for uploads */}
{progress && (
<div className="w-full bg-grey-200 rounded">
<div
className="bg-blue-500 h-2 rounded"
style={{ width: `${progress.percentage}%` }}
/>
</div>
)}
<button type="submit" disabled={processing}>
{processing ? `Uploading ${progress?.percentage ?? 0}%` : 'Upload'}
</button>
</form>
)
}
Multiple Files
interface GalleryForm {
name: string
images: File[]
}
const form = useForm<GalleryForm>({
name: '',
images: [],
})
// Handle multiple file selection
<input
type="file"
multiple
onChange={e => {
const files = e.target.files
if (files) {
setData('images', Array.from(files))
}
}}
/>
Transform Data Before Submission
Sometimes you need to modify data before it's sent. The transform method lets you do this:
function submit(e: React.FormEvent) {
e.preventDefault()
form
.transform((data) => ({
...data,
// Trim whitespace
company_name: data.company_name.trim(),
email: data.email.toLowerCase().trim(),
// Convert empty string to null
phone: data.phone || null,
// Add timestamp
submitted_at: new Date().toISOString(),
}))
.post('/leads')
}
Transform is also useful for conditional data:
form
.transform((data) => ({
...data,
// Only include avatar if it's a new file
avatar: data.avatar instanceof File ? data.avatar : undefined,
}))
.put(`/users/${user.id}`)
Submission Callbacks
Handle success and error states with callbacks:
form.post('/leads', {
onSuccess: () => {
// Redirect happens automatically, but you can do cleanup here
toast.success('Lead created!')
},
onError: (errors) => {
// Errors are already in form.errors, but you can do extra handling
toast.error('Please fix the errors below')
// Focus first error field
const firstError = Object.keys(errors)[0]
document.getElementById(firstError)?.focus()
},
onFinish: () => {
// Always runs, whether success or error
// Good for cleanup
},
})
Preserve Scroll Position
By default, Inertia scrolls to the top after form submission. Disable this:
form.post('/comments', {
preserveScroll: true, // Stay where you are
})
Or scroll only on error:
form.post('/comments', {
preserveScroll: (page) => Object.keys(page.props.errors).length > 0,
})
Preserve State
Keep component state after submission:
form.post('/search', {
preserveState: true, // Keep React state intact
})
Reset and Dirty Checking
Reset Form
// Reset everything to initial values
form.reset()
// Reset specific fields
form.reset('password', 'password_confirmation')
Check if Form Changed
const { isDirty } = useForm({
name: user.name,
email: user.email,
})
// Warn user about unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
// Show unsaved indicator
{isDirty && <span className="text-yellow-500">Unsaved changes</span>}
Update Defaults
If you want reset() to use new values:
// After successful save, update defaults
form.post('/profile', {
onSuccess: () => {
form.defaults() // Current values become the new defaults
},
})
Success States
Show temporary success messages:
const { wasSuccessful, recentlySuccessful } = useForm(...)
// wasSuccessful - true if last submission succeeded (stays true)
// recentlySuccessful - true for 2 seconds after success (then false)
{recentlySuccessful && (
<div className="text-green-500">
Saved successfully!
</div>
)}
Manual Error Handling
Set errors manually for client-side validation:
function submit(e: React.FormEvent) {
e.preventDefault()
// Client-side validation
if (data.password.length < 8) {
form.setError('password', 'Password must be at least 8 characters')
return
}
if (data.password !== data.password_confirmation) {
form.setError('password_confirmation', 'Passwords do not match')
return
}
form.post('/register')
}
// Clear errors
form.clearErrors() // Clear all
form.clearErrors('password') // Clear specific field
Multi-Step Forms
For complex forms, split them into steps:
interface OnboardingForm {
// Step 1: Account
email: string
password: string
// Step 2: Profile
name: string
company: string
// Step 3: Preferences
plan: 'starter' | 'pro' | 'enterprise'
notifications: boolean
}
export default function Onboarding() {
const [step, setStep] = useState(1)
const form = useForm<OnboardingForm>({
email: '',
password: '',
name: '',
company: '',
plan: 'starter',
notifications: true,
})
function nextStep() {
// Validate current step before proceeding
if (step === 1) {
if (!form.data.email || !form.data.password) {
form.setError('email', 'Please fill in all fields')
return
}
}
setStep(step + 1)
}
function prevStep() {
setStep(step - 1)
}
function submit(e: React.FormEvent) {
e.preventDefault()
form.post('/onboarding')
}
return (
<form onSubmit={submit}>
{/* Progress indicator */}
<div className="flex gap-2 mb-8">
{[1, 2, 3].map(n => (
<div
key={n}
className={`h-2 flex-1 rounded ${
n <= step ? 'bg-blue-500' : 'bg-grey-200'
}`}
/>
))}
</div>
{step === 1 && (
<div className="space-y-4">
<h2>Create your account</h2>
<input
type="email"
placeholder="Email"
value={form.data.email}
onChange={e => form.setData('email', e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={form.data.password}
onChange={e => form.setData('password', e.target.value)}
/>
<button type="button" onClick={nextStep}>
Continue
</button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<h2>Tell us about yourself</h2>
<input
type="text"
placeholder="Your name"
value={form.data.name}
onChange={e => form.setData('name', e.target.value)}
/>
<input
type="text"
placeholder="Company"
value={form.data.company}
onChange={e => form.setData('company', e.target.value)}
/>
<div className="flex gap-2">
<button type="button" onClick={prevStep}>
Back
</button>
<button type="button" onClick={nextStep}>
Continue
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2>Choose your plan</h2>
{['starter', 'pro', 'enterprise'].map(plan => (
<label key={plan} className="block">
<input
type="radio"
name="plan"
value={plan}
checked={form.data.plan === plan}
onChange={e => form.setData('plan', e.target.value as any)}
/>
{plan.charAt(0).toUpperCase() + plan.slice(1)}
</label>
))}
<label>
<input
type="checkbox"
checked={form.data.notifications}
onChange={e => form.setData('notifications', e.target.checked)}
/>
Send me email notifications
</label>
<div className="flex gap-2">
<button type="button" onClick={prevStep}>
Back
</button>
<button type="submit" disabled={form.processing}>
{form.processing ? 'Creating account...' : 'Complete Setup'}
</button>
</div>
</div>
)}
</form>
)
}
Reusable Form Components
Create reusable input components to reduce boilerplate:
// components/FormInput.tsx
interface FormInputProps {
label: string
name: string
type?: string
value: string
error?: string
onChange: (value: string) => void
}
export function FormInput({
label,
name,
type = 'text',
value,
error,
onChange
}: FormInputProps) {
return (
<div className="space-y-1">
<label htmlFor={name} className="block text-sm font-medium">
{label}
</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={e => onChange(e.target.value)}
className={`w-full rounded border px-3 py-2 ${
error ? 'border-red-500' : 'border-grey-300'
}`}
/>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
)
}
Use it:
<FormInput
label="Company Name"
name="company_name"
value={data.company_name}
error={errors.company_name}
onChange={value => setData('company_name', value)}
/>
The Form Component (Alternative Approach)
Inertia 2.0 also provides a <Form> component for simpler forms:
import { Form } from '@inertiajs/react'
export default function CreateComment() {
return (
<Form action="/comments" method="post">
<input type="text" name="body" />
<button type="submit">Post Comment</button>
</Form>
)
}
No state management needed, it works like a traditional HTML form but without page reloads.
Access form state through slot props:
<Form action="/comments" method="post">
{({ processing, errors }) => (
<>
<input type="text" name="body" />
{errors.body && <p>{errors.body}</p>}
<button type="submit" disabled={processing}>
{processing ? 'Posting...' : 'Post'}
</button>
</>
)}
</Form>
Use useForm when you need more control. Use <Form> for simple forms where you just need to submit data.
Remember Form State
Persist form data in history state (useful for back/forward navigation):
const form = useForm('CreateLead', {
company_name: '',
email: '',
})
The first argument is a unique key. When users navigate away and come back, their input is preserved.
For edit forms:
const form = useForm(`EditLead:${lead.id}`, {
company_name: lead.company_name,
email: lead.email,
})
The Bottom Line
Good forms aren't complicated, they're just thorough. Handle every state: loading, success, error. Give users feedback at every step. Don't lose their input.
Inertia's useForm does 80% of the work. The other 20% is:
- Typing your forms properly
- Handling edge cases (empty files, whitespace)
- Giving clear feedback
- Making multi-step flows smooth
Start simple. Get the basic form working. Then add file uploads. Then add progress indicators. Then handle edge cases. Don't try to build the perfect form component on day one.
Every form in LeadSprout follows these patterns. The onboarding flow has 4 steps with file uploads. The lead creation form handles dozens of fields. They all use useForm with these same patterns. Once you've got it working for one form, the rest are easy.