Laravel Magazine

Forms with Inertia 2.0 + React

Eric Van Johnson · Inertia React
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 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:

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.

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.