Forms

> Handle form submissions with Inertia's useForm hook for validation, error handling, and submission states.

Inertia’s useForm hook provides form state management with validation error handling and submission states.

Basic Form

// resources/js/pages/Posts/Create.tsx
import { useForm, Head, Link } from '@inertiajs/react'
import Layout from '@/layouts/Layout'

export default function CreatePost() {
  const { data, setData, post, errors, processing } = useForm({
    title: '',
    body: '',
  })

  function submit(e: React.FormEvent) {
    e.preventDefault()
    post('/posts')
  }

  return (
    <Layout>
      <Head title="Create Post" />

      <div className="container mx-auto px-4 max-w-2xl">
        <h1 className="text-3xl font-bold mb-6">Create New Post</h1>

        <form onSubmit={submit} className="space-y-6">
          <div>
            <label htmlFor="title" className="block text-sm font-medium mb-2">
              Title
            </label>
            <input
              id="title"
              type="text"
              value={data.title}
              onChange={e => setData('title', e.target.value)}
              className={`w-full px-3 py-2 border rounded-md ${
                errors.title ? 'border-red-500' : 'border-gray-300'
              }`}
            />
            {errors.title && (
              <div className="text-red-500 text-sm mt-1">{errors.title}</div>
            )}
          </div>

          <div>
            <label htmlFor="body" className="block text-sm font-medium mb-2">
              Content
            </label>
            <textarea
              id="body"
              value={data.body}
              onChange={e => setData('body', e.target.value)}
              rows={10}
              className={`w-full px-3 py-2 border rounded-md ${
                errors.body ? 'border-red-500' : 'border-gray-300'
              }`}
            />
            {errors.body && (
              <div className="text-red-500 text-sm mt-1">{errors.body}</div>
            )}
          </div>

          <div className="flex justify-between">
            <Link
              href="/posts"
              className="px-4 py-2 text-gray-600 hover:text-gray-800"
            >
              Cancel
            </Link>
            <button
              type="submit"
              disabled={processing}
              className="bg-blue-500 text-white px-6 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50"
            >
              {processing ? 'Creating...' : 'Create Post'}
            </button>
          </div>
        </form>
      </div>
    </Layout>
  )
}

useForm API

const {
  data,        // Current form data
  setData,     // Update form field
  post,        // Submit via POST
  put,         // Submit via PUT
  patch,       // Submit via PATCH
  delete: del, // Submit via DELETE
  errors,      // Validation errors from server
  processing,  // True during submission
  progress,    // Upload progress (for files)
  reset,       // Reset form to initial values
  clearErrors, // Clear validation errors
  transform,   // Transform data before submission
} = useForm({
  title: '',
  body: '',
})

Updating Fields

// Single field
setData('title', 'New Title')

// Multiple fields
setData({
  title: 'New Title',
  body: 'New Body',
})

// Callback form (for derived values)
setData(data => ({
  ...data,
  slug: data.title.toLowerCase().replace(/\s+/g, '-'),
}))

Form Methods

// Create
post('/posts')

// Update
put(`/posts/${post.id}`)
patch(`/posts/${post.id}`)

// Delete
del(`/posts/${post.id}`)

// With options
post('/posts', {
  preserveScroll: true,
  preserveState: true,
  onSuccess: () => {
    reset()
  },
  onError: (errors) => {
    console.log('Validation errors:', errors)
  },
})

File Uploads

import { useForm } from '@inertiajs/react'

export default function FileUpload() {
  const { data, setData, post, progress } = useForm({
    avatar: null as File | null,
  })

  function submit(e: React.FormEvent) {
    e.preventDefault()
    post('/profile/avatar', {
      forceFormData: true,
    })
  }

  return (
    <form onSubmit={submit}>
      <input
        type="file"
        onChange={e => setData('avatar', e.target.files?.[0] || null)}
        accept="image/*"
      />

      {progress && (
        <div className="w-full bg-gray-200 rounded-full h-2.5">
          <div
            className="bg-blue-600 h-2.5 rounded-full"
            style={{ width: `${progress.percentage}%` }}
          />
        </div>
      )}

      <button type="submit">Upload</button>
    </form>
  )
}

Transform Data

Modify data before submission:

const { data, setData, post, transform } = useForm({
  name: '',
  remember: false,
})

function submit(e: React.FormEvent) {
  e.preventDefault()

  transform(data => ({
    ...data,
    remember: data.remember ? 'on' : '',
  }))

  post('/login')
}

Resetting Forms

const { data, setData, reset } = useForm({
  title: '',
  body: '',
})

// Reset all fields
reset()

// Reset specific fields
reset('title')
reset('title', 'body')

Validation Errors

Errors come from Go validation and are keyed by field name. On the server, validate the request, then flash the errors and old input and redirect back. ctx.FlashErrors and ctx.FlashInput write short-lived encrypted flash cookies; the view layer reads them on the next render and injects them as the errors and old props:

// Go handler
import (
    "github.com/velocitykode/velocity/router"
    "github.com/velocitykode/velocity/validation"
    "github.com/velocitykode/velocity/view"
)

func (c *PostHandler) Store(ctx *router.Context) error {
    // CheckW threads the ResponseWriter through so an oversized body can
    // signal the connection to close. The error return is a malformed
    // rule set (a handler bug), never a field-level failure.
    result, err := validation.CheckW(ctx.Response, ctx.Request, validation.Rules{
        "title": {validation.Required(), validation.Min(3)},
        "body":  {validation.Required(), validation.Min(10)},
    })
    if err != nil {
        return err
    }

    if result.HasErrors() {
        ctx.FlashErrors(result.All())
        ctx.FlashInput(result.Old())
        view.Back(ctx)
        return nil
    }

    // Create post...
    return nil
}

Rules are typed constructor values collected in a validation.Rules set keyed by field, not strings. result.All() is a map[string]string (one message per field), which is the shape Inertia’s errors prop expects. result.Old() strips sensitive-looking fields (anything whose name contains password, token, secret, card, otp, and similar) before handing input back for replay, so a password never round-trips to the client.

Rules that hit the database
validation.Unique and validation.Exists execute in the validation/dbrules subpackage, which owns the orm dependency. Use dbrules.CheckWithDBW(ctx.Response, ctx.Request, rules, db) when your rule set names them, or let ctx.Validate / ctx.BindValid / vform.Form do it, since those resolve the database from the request’s service container for you.

vform.Form

For the common case, vform.Form[T] binds the request body into a typed struct, validates it, and on failure flashes errors plus old input, redirects back, and returns router.ErrValidationAborted so the handler can return early without the router emitting an error response:

import (
    "github.com/velocitykode/velocity/router"
    "github.com/velocitykode/velocity/validation"
    "github.com/velocitykode/velocity/validation/vform"
)

type StorePost struct {
    Title string `json:"title"`
    Body  string `json:"body"`
}

func (p *StorePost) Rules() validation.Rules {
    return validation.Rules{
        "title": {validation.Required(), validation.Min(3)},
        "body":  {validation.Required(), validation.Min(10)},
    }
}

func (c *PostHandler) Store(ctx *router.Context) error {
    post, err := vform.Form[StorePost](ctx)
    if err != nil {
        return err // ErrValidationAborted is handled by the router
    }

    // Create post using post.Title / post.Body...
    return nil
}

vform.FormRequest is an alias for router.Validatable, so the same StorePost also works with ctx.BindValid(&input) when a handler needs validation without the flash-and-redirect-back flow (a JSON endpoint, for instance). One declaration, both entry points.

// React component
{errors.title && (
  <span className="text-red-500">{errors.title}</span>
)}

Form Callbacks

post('/posts', {
  onBefore: () => {
    // Called before request
    return confirm('Are you sure?')
  },
  onStart: () => {
    // Request started
  },
  onProgress: (progress) => {
    // Upload progress update
  },
  onSuccess: (page) => {
    // Request succeeded
    reset()
  },
  onError: (errors) => {
    // Validation errors
  },
  onCancel: () => {
    // Request was cancelled
  },
  onFinish: () => {
    // Always called (success or error)
  },
})

Preserving State

// Keep scroll position after submission
post('/posts', { preserveScroll: true })

// Keep form state on error (default for errors)
post('/posts', { preserveState: true })

// Replace history entry (no back button to form)
post('/posts', { replace: true })

Best Practices

  1. Disable Buttons - Use processing to disable submit during submission
  2. Show Progress - Display upload progress for file forms
  3. Clear on Success - Reset form after successful submission
  4. Preserve Scroll - Use preserveScroll for inline forms
  5. Transform Data - Use transform for data modifications before submit
  6. Type Props - Define TypeScript interfaces for form data