December 13th, 2025

Forms with Inertia 2.0 + React

Forms with Inertia 2.0 + React
Sponsored by
Table of Contents

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:

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 <input
21 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 <input
34 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 state
  • setData - update individual fields
  • post, put, patch, delete - submit methods
  • processing - true whilst submitting
  • errors - validation errors from Laravel
  • reset - reset to initial values

TypeScript with useForm

Always 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 names
17// - data.company_name is a string
18// - 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})

Handling Different Input Types

Text Inputs

1<input
2 type="text"
3 value={data.name}
4 onChange={e => setData('name', e.target.value)}
5/>

Checkboxes

1<input
2 type="checkbox"
3 checked={data.subscribe}
4 onChange={e => setData('subscribe', e.target.checked)}
5/>

Select Dropdowns

1<select
2 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>

Radio Buttons

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))}

Textareas

1<textarea
2 value={data.description}
3 onChange={e => setData('description', e.target.value)}
4 rows={4}
5/>

File Uploads

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 <input
20 type="text"
21 value={data.title}
22 onChange={e => setData('title', e.target.value)}
23 />
24 
25 <input
26 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 <div
34 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}

Multiple Files

1interface GalleryForm {
2 name: string
3 images: File[]
4}
5 
6const form = useForm<GalleryForm>({
7 name: '',
8 images: [],
9})
10 
11// Handle multiple file selection
12<input
13 type="file"
14 multiple
15 onChange={e => {
16 const files = e.target.files
17 if (files) {
18 setData('images', Array.from(files))
19 }
20 }}
21/>

Transform Data Before Submission

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 null
11 phone: data.phone || null,
12 // Add timestamp
13 submitted_at: new Date().toISOString(),
14 }))
15 .post('/leads')
16}

Transform is also useful for conditional data:

1form
2 .transform((data) => ({
3 ...data,
4 // Only include avatar if it's a new file
5 avatar: data.avatar instanceof File ? data.avatar : undefined,
6 }))
7 .put(`/users/${user.id}`)

Submission Callbacks

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 field
11 const firstError = Object.keys(errors)[0]
12 document.getElementById(firstError)?.focus()
13 },
14 onFinish: () => {
15 // Always runs, whether success or error
16 // Good for cleanup
17 },
18})

Preserve Scroll Position

By default, Inertia scrolls to the top after form submission. Disable this:

1form.post('/comments', {
2 preserveScroll: true, // Stay where you are
3})

Or scroll only on error:

1form.post('/comments', {
2 preserveScroll: (page) => Object.keys(page.props.errors).length > 0,
3})

Preserve State

Keep component state after submission:

1form.post('/search', {
2 preserveState: true, // Keep React state intact
3})

Reset and Dirty Checking

Reset Form

1// Reset everything to initial values
2form.reset()
3 
4// Reset specific fields
5form.reset('password', 'password_confirmation')

Check if Form Changed

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 indicator
20{isDirty && <span className="text-yellow-500">Unsaved changes</span>}

Update Defaults

If you want reset() to use new values:

1// After successful save, update defaults
2form.post('/profile', {
3 onSuccess: () => {
4 form.defaults() // Current values become the new defaults
5 },
6})

Success States

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)}

Manual Error Handling

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 return
13 }
14 
15 form.post('/register')
16}
17 
18// Clear errors
19form.clearErrors() // Clear all
20form.clearErrors('password') // Clear specific field

Multi-Step Forms

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 Continue
101 </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 <input
112 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 <input
123 type="checkbox"
124 checked={form.data.notifications}
125 onChange={e => form.setData('notifications', e.target.checked)}
126 />
127 Send me email notifications
128 </label>
129 <div className="flex gap-2">
130 <button type="button" onClick={prevStep}>
131 Back
132 </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}

Reusable Form Components

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 onChange
18}: FormInputProps) {
19 return (
20 <div className="space-y-1">
21 <label htmlFor={name} className="block text-sm font-medium">
22 {label}
23 </label>
24 <input
25 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<FormInput
2 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/>

The Form Component (Alternative Approach)

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.

Remember Form State

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})

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.

Marian Pop

Written by

Marian Pop

Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Comments

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.