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.
Let's start with the basics. The useForm hook gives you everything you need:
1import { useForm } from '@inertiajs/react' 2 3export default function CreateLead() { 4 const { data, setData, post, processing, errors, reset } = useForm({ 5 company_name: '', 6 email: '', 7 website: '', 8 notes: '', 9 })10 11 function submit(e: React.FormEvent) {12 e.preventDefault()13 post('/leads')14 }15 16 return (17 <form onSubmit={submit}>18 <div>19 <label htmlFor="company_name">Company Name</label>20 <input21 id="company_name"22 type="text"23 value={data.company_name}24 onChange={e => setData('company_name', e.target.value)}25 />26 {errors.company_name && (27 <p className="text-red-500 text-sm">{errors.company_name}</p>28 )}29 </div>30 31 <div>32 <label htmlFor="email">Email</label>33 <input34 id="email"35 type="email"36 value={data.email}37 onChange={e => setData('email', e.target.value)}38 />39 {errors.email && (40 <p className="text-red-500 text-sm">{errors.email}</p>41 )}42 </div>43 44 <button type="submit" disabled={processing}>45 {processing ? 'Creating...' : 'Create Lead'}46 </button>47 </form>48 )49}
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 valuesAlways type your forms:
1interface LeadForm { 2 company_name: string 3 email: string 4 website: string 5 notes: string 6} 7 8const { data, setData, post, errors } = useForm<LeadForm>({ 9 company_name: '',10 email: '',11 website: '',12 notes: '',13})14 15// Now TypeScript knows:16// - setData only accepts valid field names17// - data.company_name is a string18// - errors can have company_name, email, website, or notes
For forms with files:
1interface ProfileForm { 2 name: string 3 email: string 4 avatar: File | null 5} 6 7const form = useForm<ProfileForm>({ 8 name: user.name, 9 email: user.email,10 avatar: null,11})
1<input2 type="text"3 value={data.name}4 onChange={e => setData('name', e.target.value)}5/>
1<input2 type="checkbox"3 checked={data.subscribe}4 onChange={e => setData('subscribe', e.target.checked)}5/>
1<select2 value={data.country}3 onChange={e => setData('country', e.target.value)}4>5 <option value="">Select country</option>6 <option value="uk">United Kingdom</option>7 <option value="us">United States</option>8 <option value="ro">Romania</option>9</select>
1{['small', 'medium', 'large'].map(size => ( 2 <label key={size}> 3 <input 4 type="radio" 5 name="size" 6 value={size} 7 checked={data.size === size} 8 onChange={e => setData('size', e.target.value)} 9 />10 {size}11 </label>12))}
1<textarea2 value={data.description}3 onChange={e => setData('description', e.target.value)}4 rows={4}5/>
Inertia handles file uploads automatically. When your form data includes a File, it converts everything to FormData behind the scenes.
1interface UploadForm { 2 title: string 3 document: File | null 4} 5 6export default function UploadDocument() { 7 const { data, setData, post, processing, progress, errors } = useForm<UploadForm>({ 8 title: '', 9 document: null,10 })11 12 function submit(e: React.FormEvent) {13 e.preventDefault()14 post('/documents')15 }16 17 return (18 <form onSubmit={submit}>19 <input20 type="text"21 value={data.title}22 onChange={e => setData('title', e.target.value)}23 />24 25 <input26 type="file"27 onChange={e => setData('document', e.target.files?.[0] ?? null)}28 />29 30 {/* Progress bar for uploads */}31 {progress && (32 <div className="w-full bg-grey-200 rounded">33 <div34 className="bg-blue-500 h-2 rounded"35 style={{ width: `${progress.percentage}%` }}36 />37 </div>38 )}39 40 <button type="submit" disabled={processing}>41 {processing ? `Uploading ${progress?.percentage ?? 0}%` : 'Upload'}42 </button>43 </form>44 )45}
1interface GalleryForm { 2 name: string 3 images: File[] 4} 5 6const form = useForm<GalleryForm>({ 7 name: '', 8 images: [], 9})10 11// Handle multiple file selection12<input13 type="file"14 multiple15 onChange={e => {16 const files = e.target.files17 if (files) {18 setData('images', Array.from(files))19 }20 }}21/>
Sometimes you need to modify data before it's sent. The transform method lets you do this:
1function submit(e: React.FormEvent) { 2 e.preventDefault() 3 4 form 5 .transform((data) => ({ 6 ...data, 7 // Trim whitespace 8 company_name: data.company_name.trim(), 9 email: data.email.toLowerCase().trim(),10 // Convert empty string to null11 phone: data.phone || null,12 // Add timestamp13 submitted_at: new Date().toISOString(),14 }))15 .post('/leads')16}
Transform is also useful for conditional data:
1form2 .transform((data) => ({3 ...data,4 // Only include avatar if it's a new file5 avatar: data.avatar instanceof File ? data.avatar : undefined,6 }))7 .put(`/users/${user.id}`)
Handle success and error states with callbacks:
1form.post('/leads', { 2 onSuccess: () => { 3 // Redirect happens automatically, but you can do cleanup here 4 toast.success('Lead created!') 5 }, 6 onError: (errors) => { 7 // Errors are already in form.errors, but you can do extra handling 8 toast.error('Please fix the errors below') 9 10 // Focus first error field11 const firstError = Object.keys(errors)[0]12 document.getElementById(firstError)?.focus()13 },14 onFinish: () => {15 // Always runs, whether success or error16 // Good for cleanup17 },18})
By default, Inertia scrolls to the top after form submission. Disable this:
1form.post('/comments', {2 preserveScroll: true, // Stay where you are3})
Or scroll only on error:
1form.post('/comments', {2 preserveScroll: (page) => Object.keys(page.props.errors).length > 0,3})
Keep component state after submission:
1form.post('/search', {2 preserveState: true, // Keep React state intact3})
1// Reset everything to initial values2form.reset()3 4// Reset specific fields5form.reset('password', 'password_confirmation')
1const { isDirty } = useForm({ 2 name: user.name, 3 email: user.email, 4}) 5 6// Warn user about unsaved changes 7useEffect(() => { 8 const handleBeforeUnload = (e: BeforeUnloadEvent) => { 9 if (isDirty) {10 e.preventDefault()11 e.returnValue = ''12 }13 }14 15 window.addEventListener('beforeunload', handleBeforeUnload)16 return () => window.removeEventListener('beforeunload', handleBeforeUnload)17}, [isDirty])18 19// Show unsaved indicator20{isDirty && <span className="text-yellow-500">Unsaved changes</span>}
If you want reset() to use new values:
1// After successful save, update defaults2form.post('/profile', {3 onSuccess: () => {4 form.defaults() // Current values become the new defaults5 },6})
Show temporary success messages:
1const { wasSuccessful, recentlySuccessful } = useForm(...) 2 3// wasSuccessful - true if last submission succeeded (stays true) 4// recentlySuccessful - true for 2 seconds after success (then false) 5 6{recentlySuccessful && ( 7 <div className="text-green-500"> 8 Saved successfully! 9 </div>10)}
Set errors manually for client-side validation:
1function submit(e: React.FormEvent) { 2 e.preventDefault() 3 4 // Client-side validation 5 if (data.password.length < 8) { 6 form.setError('password', 'Password must be at least 8 characters') 7 return 8 } 9 10 if (data.password !== data.password_confirmation) {11 form.setError('password_confirmation', 'Passwords do not match')12 return13 }14 15 form.post('/register')16}17 18// Clear errors19form.clearErrors() // Clear all20form.clearErrors('password') // Clear specific field
For complex forms, split them into steps:
1interface OnboardingForm { 2 // Step 1: Account 3 email: string 4 password: string 5 // Step 2: Profile 6 name: string 7 company: string 8 // Step 3: Preferences 9 plan: 'starter' | 'pro' | 'enterprise' 10 notifications: boolean 11} 12 13export default function Onboarding() { 14 const [step, setStep] = useState(1) 15 16 const form = useForm<OnboardingForm>({ 17 email: '', 18 password: '', 19 name: '', 20 company: '', 21 plan: 'starter', 22 notifications: true, 23 }) 24 25 function nextStep() { 26 // Validate current step before proceeding 27 if (step === 1) { 28 if (!form.data.email || !form.data.password) { 29 form.setError('email', 'Please fill in all fields') 30 return 31 } 32 } 33 setStep(step + 1) 34 } 35 36 function prevStep() { 37 setStep(step - 1) 38 } 39 40 function submit(e: React.FormEvent) { 41 e.preventDefault() 42 form.post('/onboarding') 43 } 44 45 return ( 46 <form onSubmit={submit}> 47 {/* Progress indicator */} 48 <div className="flex gap-2 mb-8"> 49 {[1, 2, 3].map(n => ( 50 <div 51 key={n} 52 className={`h-2 flex-1 rounded ${ 53 n <= step ? 'bg-blue-500' : 'bg-grey-200' 54 }`} 55 /> 56 ))} 57 </div> 58 59 {step === 1 && ( 60 <div className="space-y-4"> 61 <h2>Create your account</h2> 62 <input 63 type="email" 64 placeholder="Email" 65 value={form.data.email} 66 onChange={e => form.setData('email', e.target.value)} 67 /> 68 <input 69 type="password" 70 placeholder="Password" 71 value={form.data.password} 72 onChange={e => form.setData('password', e.target.value)} 73 /> 74 <button type="button" onClick={nextStep}> 75 Continue 76 </button> 77 </div> 78 )} 79 80 {step === 2 && ( 81 <div className="space-y-4"> 82 <h2>Tell us about yourself</h2> 83 <input 84 type="text" 85 placeholder="Your name" 86 value={form.data.name} 87 onChange={e => form.setData('name', e.target.value)} 88 /> 89 <input 90 type="text" 91 placeholder="Company" 92 value={form.data.company} 93 onChange={e => form.setData('company', e.target.value)} 94 /> 95 <div className="flex gap-2"> 96 <button type="button" onClick={prevStep}> 97 Back 98 </button> 99 <button type="button" onClick={nextStep}>100 Continue101 </button>102 </div>103 </div>104 )}105 106 {step === 3 && (107 <div className="space-y-4">108 <h2>Choose your plan</h2>109 {['starter', 'pro', 'enterprise'].map(plan => (110 <label key={plan} className="block">111 <input112 type="radio"113 name="plan"114 value={plan}115 checked={form.data.plan === plan}116 onChange={e => form.setData('plan', e.target.value as any)}117 />118 {plan.charAt(0).toUpperCase() + plan.slice(1)}119 </label>120 ))}121 <label>122 <input123 type="checkbox"124 checked={form.data.notifications}125 onChange={e => form.setData('notifications', e.target.checked)}126 />127 Send me email notifications128 </label>129 <div className="flex gap-2">130 <button type="button" onClick={prevStep}>131 Back132 </button>133 <button type="submit" disabled={form.processing}>134 {form.processing ? 'Creating account...' : 'Complete Setup'}135 </button>136 </div>137 </div>138 )}139 </form>140 )141}
Create reusable input components to reduce boilerplate:
1// components/FormInput.tsx 2interface FormInputProps { 3 label: string 4 name: string 5 type?: string 6 value: string 7 error?: string 8 onChange: (value: string) => void 9}10 11export function FormInput({12 label,13 name,14 type = 'text',15 value,16 error,17 onChange18}: FormInputProps) {19 return (20 <div className="space-y-1">21 <label htmlFor={name} className="block text-sm font-medium">22 {label}23 </label>24 <input25 id={name}26 name={name}27 type={type}28 value={value}29 onChange={e => onChange(e.target.value)}30 className={`w-full rounded border px-3 py-2 ${31 error ? 'border-red-500' : 'border-grey-300'32 }`}33 />34 {error && (35 <p className="text-sm text-red-500">{error}</p>36 )}37 </div>38 )39}
Use it:
1<FormInput2 label="Company Name"3 name="company_name"4 value={data.company_name}5 error={errors.company_name}6 onChange={value => setData('company_name', value)}7/>
Inertia 2.0 also provides a <Form> component for simpler forms:
1import { Form } from '@inertiajs/react' 2 3export default function CreateComment() { 4 return ( 5 <Form action="/comments" method="post"> 6 <input type="text" name="body" /> 7 <button type="submit">Post Comment</button> 8 </Form> 9 )10}
No state management needed, it works like a traditional HTML form but without page reloads.
Access form state through slot props:
1<Form action="/comments" method="post"> 2 {({ processing, errors }) => ( 3 <> 4 <input type="text" name="body" /> 5 {errors.body && <p>{errors.body}</p>} 6 <button type="submit" disabled={processing}> 7 {processing ? 'Posting...' : 'Post'} 8 </button> 9 </>10 )}11</Form>
Use useForm when you need more control. Use <Form> for simple forms where you just need to submit data.
Persist form data in history state (useful for back/forward navigation):
1const form = useForm('CreateLead', {2 company_name: '',3 email: '',4})
The first argument is a unique key. When users navigate away and come back, their input is preserved.
For edit forms:
1const form = useForm(`EditLead:${lead.id}`, {2 company_name: lead.company_name,3 email: lead.email,4})
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:
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.
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.