inertia-rails-forms

>-

Skill file

Preview skill file
---
name: inertia-rails-forms
description: >-
  Full-stack form handling for Inertia Rails: create, edit, delete, multi-step wizard, and file upload
  forms with validation errors and progress tracking. React examples inline; Vue and Svelte equivalents
  in references. Use when building any form, handling file uploads, multi-step forms, client-side validation,
  or wiring form submission to Rails controllers. NEVER react-hook-form. Use `<Form>` for simple forms,
  useForm for dynamic/programmatic control.
---

# Inertia Rails Forms

Full-stack form handling for Inertia.js + Rails.

**Before building a form, ask:**
- **Simple create/edit?** → `<Form>` component (no state management needed)
- **Requires per-field UI elements?** → Still `<Form>`.
  React `useState` for UI state (preview URL, file size display) is independent
  of form data — `<Form>` handles the submission; `useState` handles the UI.
- **Multi-step wizard, dynamic fields (add/remove inputs), or form data
  shared with sibling components (e.g., live preview panel)?** → `useForm` hook
- **Tempted by react-hook-form?** → Don't. Inertia's `<Form>` already handles CSRF
  tokens, redirect following, error mapping from Rails, processing state, file upload
  detection, and history state. react-hook-form would duplicate or fight all of this.

**When NOT to use `<Form>` or `useForm`:**
- **Data lookups** — not a form submission. Use `router.get` with debounce + `preserveState`, or raw `fetch` for **large datasets**
- **Inline single-field edits without navigation** – `router.patch` directly, or `useForm` if you need error display on the field

**NEVER:**
- Use `react-hook-form`, `vee-validate`, or `sveltekit-superforms` — Inertia `<Form>` already handles CSRF, redirect following, error mapping, processing state, and file detection. These libraries fight Inertia's request lifecycle.
- Pass `data={...}` to `<Form>` — it has no `data` prop. Data comes from input `name` attributes automatically. `data` is a `useForm` concept.
- Use `useForm` for simple create/edit — `<Form>` handles these without state management. Reserve `useForm` for multi-step wizards, dynamic add/remove fields, or form data shared with sibling components.
- Use controlled `value=` instead of `defaultValue` on inputs — controlled inputs bypass `<Form>`'s dirty tracking, making `isDirty` always `false`.
- Omit `value="1"` on checkboxes — without it, the browser submits `"on"` and Rails won't cast to boolean correctly.
- Call `useForm` inside a loop or conditional — it's a hook (React rules apply). Create one form instance per logical form.

## `<Form>` Component (Preferred)

The simplest way to handle forms. Collects data from input `name` attributes
automatically — no manual state management needed. `<Form>` has NO `data` prop —
do NOT pass `data={...}` (that's a `useForm` concept). For edit forms, use
`defaultValue` on inputs.

**Use render function children** `{({ errors, processing }) => (...)}` to
access form state. Plain children work but give no access to errors,
processing, or progress.

```tsx
import { Form } from '@inertiajs/react'

export default function CreateUser() {
  return (
    <Form method="post" action="/users">
      {({ errors, processing }) => (
        <>
          <input type="text" name="name" />
          {errors.name && <span className="error">{errors.name}</span>}

          <input type="email" name="email" />
          {errors.email && <span className="error">{errors.email}</span>}

          <button type="submit" disabled={processing}>
            {processing ? 'Creating...' : 'Create User'}
          </button>
        </>
      )}
    </Form>
  )
}

// Plain children — valid but no access to errors/processing/progress:
// <Form method="post" action="/users">
//   <input name="name" />
//   <button type="submit">Create</button>
// </Form>
```

### Delete Form

```tsx
<Form method="delete" action={`/posts/${post.id}`}>
  {({ processing }) => (
    <button type="submit" disabled={processing}>
      {processing ? 'Deleting...' : 'Delete Post'}
    </button>
  )}
</Form>
```

### Key Render Function Properties

| Property | Type | Purpose |
|----------|------|---------|
| `errors` | `Record<string, string>` | Validation errors keyed by field name |
| `processing` | `boolean` | True while request is in flight |
| `progress` | `{ percentage: number } \| null` | Upload progress (file uploads only) |
| `hasErrors` | `boolean` | True if any errors exist |
| `wasSuccessful` | `boolean` | True after last submit succeeded |
| `recentlySuccessful` | `boolean` | True for 2s after success — ideal for "Saved!" feedback |
| `isDirty` | `boolean` | True if any input changed from initial value |
| `reset` | `(...fields) => void` | Reset specific fields or all fields |
| `clearErrors` | `(...fields) => void` | Clear specific errors or all errors |

Additional `<Form>` props (`errorBag`, `only`, `resetOnSuccess`,
event callbacks like `onBefore`, `onSuccess`, `onError`, `onProgress`) are
documented in `references/advanced-forms.md` — see loading trigger below.

### Edit Form (Pre-populated)

Use `method="patch"` and uncontrolled defaults:
- Text/textarea → `defaultValue`
- Checkbox/radio → `defaultChecked`
- Select → `defaultValue` on `<select>`

> Checkbox without explicit `value` submits `"on"` — set `value="1"` so Rails
> casts to boolean correctly.

```tsx
<Form method="patch" action={`/posts/${post.id}`}>
  {({ errors, processing }) => (
    <>
      <input type="text" name="title" defaultValue={post.title} />
      {errors.title && <span className="error">{errors.title}</span>}

      <label>
        <input type="checkbox" name="published" value="1"
          defaultChecked={post.published} />
        Published
      </label>

      <button type="submit" disabled={processing}>
        {processing ? 'Saving...' : 'Update Post'}
      </button>
    </>
  )}
</Form>
```

### Transforming Data

Use the `transform` prop to reshape data before submission without `useForm`.

For advanced `transform` with `useForm`, see `references/advanced-forms.md`.

### External Access with `formRef`

The ref exposes the same methods and state as render function props (`FormComponentSlotProps`).
Use when you need to interact with the form from outside `<Form>`.
Key ref methods: `submit()`, `reset()`, `clearErrors()`, `setError()`,
`getData()`, `getFormData()`, `validate()`, `touch()`, `defaults()`.
State: `errors`, `processing`, `progress`, `isDirty`, `hasErrors`,
`wasSuccessful`, `recentlySuccessful`.

```tsx
import {useRef} from 'react'
import {Form} from '@inertiajs/react'
import type {FormComponentRef} from '@inertiajs/core'

export default function CreateUser() {
  const formRef = useRef<FormComponentRef>(null)

  return (
    <>
      <Form ref={formRef} method='post' action='/users'>
        {({errors}) => (
          <>
            <input type='text' name='name'/>
            {errors.name && <span className='error'>{errors.name}</span>}
          </>
        )}
      </Form>
      <button onClick={() => formRef.current?.submit()}>Submit</button>
      <button onClick={() => formRef.current?.reset()}>Reset</button>
    </>
  )
}
```

## `useForm` Hook

Use `useForm` only for multi-step wizards, dynamic add/remove fields, or
form data shared with sibling components.

**MANDATORY — READ ENTIRE FILE** when using `useForm` hook, `transform`,
`errorBag`, `resetOnSuccess`, multi-step forms, or client-side validation
with `setError`:
[`references/advanced-forms.md`](references/advanced-forms.md) (~330 lines) — full
`useForm` API, transform examples, error bag scoping, multi-step wizard
patterns, and client-side validation.

**Do NOT load** `advanced-forms.md` when using `<Form>` component for simple
create/edit forms — the examples above are sufficient.

## File Uploads

Both `<Form>` and `useForm` auto-detect files and switch to `FormData`.
Upload progress is built into the render function — destructure `progress`
alongside `errors` and `processing`:

```tsx
type Props = { user: User }

export default function EditProfile({ user }: Props) {
  return (
    <Form method="patch" action="/profile">
      {({ errors, processing, progress }) => (
        <>
          <input type="text" name="name" defaultValue={user.name} />
          {errors.name && <span className="error">{errors.name}</span>}

          <input type="file" name="avatar" />
          {errors.avatar && <span className="error">{errors.avatar}</span>}

          {progress && (
            <progress value={progress.percentage ?? 0} max="100" />
          )}

          <button type="submit" disabled={processing}>
            {processing ? 'Uploading...' : 'Save'}
          </button>
        </>
      )}
    </Form>
  )
}
```

**Choosing `<Form>` vs `useForm` for uploads:**
- **File submits with other fields** (avatar + name, one Save button) → file input inside `<Form>`
- **Standalone immediate upload** (uploads on select, no Save button) → `<Form>` + `formRef.submit()` on change
- **Drag-and-drop upload** → `useForm` (dropped files aren't in DOM inputs, `setData` is cleaner)

Preview / validation → `useState` alongside either approach, see
[`references/file-uploads.md`](references/file-uploads.md).

## Vue / Svelte

All examples above use React syntax. For Vue 3 or Svelte equivalents:

- **Vue 3**: [`references/vue.md`](references/vue.md) — `<Form>` with `#default` scoped slot, `useForm` returns reactive proxy (`form.email` not `setData`), `v-model` binding
- **Svelte**: [`references/svelte.md`](references/svelte.md) — `<Form>` with `{#snippet}` syntax, `useForm` returns Writable store (`$form.email`), `bind:value`, ref exposes methods only (not reactive state)

**MANDATORY — READ THE MATCHING FILE** when the project uses Vue or Svelte.

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| No access to `errors`/`processing` | Plain children instead of render function | `<Form>` children should be `{({ errors, processing }) => (...)}` |
| Form sends GET instead of POST | Missing `method` prop | Add `method="post"` (or `"patch"`, `"delete"`) |
| File upload sends empty body | PUT/PATCH with file | Multipart limitation — Inertia auto-adds `_method` field to convert to POST |
| Errors don't clear after fixing field | Stale error state | Errors auto-clear on next submit; use `clearErrors('field')` for immediate clearing |
| `isDirty` always false | Using `value` instead of `defaultValue` | Controlled inputs (`value=`) bypass dirty tracking — use `defaultValue` |
| `progress` is always `null` | No file input in form | Progress tracking only activates when `<Form>` detects a file input |
| Checkbox sends `"on"` | No explicit `value` | Add `value="1"` to checkbox inputs |
| Form submits twice in dev | React StrictMode double-invocation | Normal in development — StrictMode remounts components. Only fires once in production |
| Used `useForm` for file upload with preview | `onChange` + `useState` mistaken for "programmatic data manipulation" | `<Form>` + `useState` for preview UI. `useForm` is only needed when form *submission data* must live in React state (multi-step, dynamic add/remove fields). File preview is local UI state, not form data |

## Related Skills
- **Server-side PRG & errors** → `inertia-rails-controllers` (redirect_back, to_hash, flash)
- **shadcn inputs** → `shadcn-inertia` (Input/Select adaptation, toast UI)
- **Page props typing** → `inertia-rails-typescript` (`type Props` not `interface`, TS2344)

**MANDATORY — READ ENTIRE FILE** when handling file uploads with image preview,
Active Storage, or direct uploads:
[`references/file-uploads.md`](references/file-uploads.md) (~200 lines) — image preview
with `<Form>`, Active Storage integration, direct upload setup, multiple files,
and progress tracking.

Source

Creator's repository · inertia-rails/skills

View on GitHub

Security

Security checks in progress
Results will appear here once audits complete
What this skill can do
Reads your filesConnects to the internetRuns code on your machine
Checked by 3 independent security firms
Does it try to trick the AI?Not yet checkedPending · Gen Agent Trust Hub
Does it sneak in hidden code?Not yet checkedPending · Socket
Does it have known bugs?Not yet checkedPending · Snyk