Forms

Bind fields through Conform, never by hand

Take the binding off the field metadata: the conform-* variant where one exists, getInputProps (or useInputControl) where it doesn't, and getFormProps on the form element. Per-field useState and a hand-passed name are the failure mode.

Tier 1errorlintbind-fields-through-conform

Validation is yours. Wiring is the library's.

What this catches

An agent wires a form field the way it has seen a thousand times — `useState`, `value`, `onChange`, a literal `name` — instead of reading the binding off the field metadata. The form still submits, so nothing looks broken; what is missing is the error wiring, the default value, and the repopulation after a failed submit.

Why

Field metadata carries more than a name. getInputProps derives the name, the id, the form id, required, the default value, aria-invalid, and the aria-describedby that points at the error message. Passing name="email" by hand gets you one of those seven and drops the rest — and because the field still submits, nothing looks broken. What breaks is invisible: the control no longer announces its error, no longer repopulates after a failed submit, and no longer resets with the form.

Per-field useState is the other half of the same mistake. The form already tracks every value, its dirty state, and its errors; a second copy in component state disagrees with the first the moment anything non-trivial happens — a server-side rejection that should refill the form, a reset, a default arriving from a loader. The bug always surfaces later than the code that caused it.

Conform itself permits the manual form — its docs read fields.email.name and fields.email.initialValue off the metadata directly, and the tutorial only reaches for the helpers at the end, to "minimize the boilerplate" for native inputs. That is the honest status of this rule: manual access is legal Conform, and it is fine as long as you also wire aria-invalid and aria-describedby yourself. This rule exists because that last part is the part everyone skips, and because ui-lib ships components where the wiring is already done.

Thirteen of the library's components ship a Conform-bound variant, chosen because their binding is subtle enough to be worth wrapping (a Select that has to submit through a hidden input, a DatePicker that has to serialise a calendar value). Everything else you bind yourself with getInputProps, or with useInputControl when the control has no native form value. That is not a worse path — it is the same metadata, read explicitly.

Use this instead

<Checkbox>
TextField / Input
  • ConformField from @/components/conform-fieldtext, email, and password fields
<NumberField>
<Select>
DateField / DatePicker
ColorPicker / ColorSwatchPicker
<DaySchedule>
<StoragePicker>
<form>
  • getFormProps from @conform-to/reactalways — it supplies id, onSubmit, noValidate, and the aria-describedby for form-level errors
  • Form from react-routerthe form posts to a route action, which is where the schema is re-parsed
Everything else (Slider, TagField, InputOTP, ColorField, DropZone, …)
  • getInputProps from @conform-to/reactthe control renders a real input
  • useInputControl from @conform-to/reactthe control has no native form value — it returns value/change/focus/blur and is what Conform documents for custom inputs

Twenty-one of the library's form controls have no conform-* variant. Binding them explicitly is expected; reaching for useState instead is not.

Wrong / right

A checkbox wired by hand

Don't

const [accepted, setAccepted] = useState(false)
const [error, setError] = useState<string>()

<Checkbox name="terms" isSelected={accepted} onChange={setAccepted} isInvalid={!!error}>
  I accept the terms
</Checkbox>
{error && <p className="text-sm text-red-500">{error}</p>}

Do

import { ConformCheckbox } from "@/components/conform-checkbox"

<ConformCheckbox field={fields.terms} label="I accept the terms" />

The hand-wired version sets one of the seven things the metadata carries. It also has to invent its own error state, which is how the error message ends up unconnected to the control — see the tier 2 rule.

The form element itself

Don't

<form id={form.id} onSubmit={form.onSubmit} noValidate>
  {/* fields */}
</form>

Do

import { getFormProps } from "@conform-to/react"
import { Form } from "react-router"

<Form method="post" {...getFormProps(form)}>
  {/* fields */}
</Form>

getFormProps adds the aria-describedby for form-level errors that the hand-written trio leaves off, and React Router's Form posts to the route action — which is where the schema gets parsed by something the user cannot edit.

A control with no conform-* variant

Don't

const [volume, setVolume] = useState(50)

<Slider value={volume} onChange={setVolume} />
<input type="hidden" name="volume" value={volume} />

Do

import { useInputControl } from "@conform-to/react"

const volume = useInputControl(fields.volume)

<Slider value={Number(volume.value ?? 0)} onChange={(next) => volume.change(String(next))} />

The hidden-input trick is the tell. useInputControl is the supported way to bind a control that has no native form value, and it keeps the value inside the form's state where reset and lastResult can reach it.

How to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: This is a ui-lib convention, not a Conform requirement — Conform's own examples pick metadata off property by property, which is exactly the shape the check looks for. It fires only on ui-lib control names, so Conform's native-input examples do not trip it. It cannot see a field bound entirely through useState with a literal name; that shape needs review, and it is the common one in code written before the conform-* variants existed.

Biome — GritQL plugin

biome

Biome has no built-in rule for this one, so it ships as a GritQL plugin. Save it as ui-lib-rules/bind-fields-through-conform.grit and add that path to `plugins` in your biome.jsonc. Its documented exceptions are compiled in as `$filename` guards, because Biome's overrides do not scope plugins.

// Bind fields through Conform, never by hand
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/bind-fields-through-conform.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/bind-fields-through-conform

language js;

or {
  JsxOpeningElement(name = $el, attributes = $attrs),
  JsxSelfClosingElement(name = $el, attributes = $attrs)
} as $control where {
  $el <: r"^(?:Checkbox|Select|MultipleSelect|AsyncSelect|AsyncMultipleSelect|NumberField|DateField|DatePicker|ColorPicker|ColorSwatchPicker|DaySchedule|StoragePicker|TextField)$",
  $attrs <: contains or { `$meta.name`, `$meta.errors`, `$meta.initialValue`, `$meta.errorId`, `$meta.formId` },
  // documented exception: components/ui/**
  not $filename <: r".*components/ui/.*",
  // documented exception: src/components/**
  not $filename <: r".*src/components/.*",
  register_diagnostic(
    span = $control,
    message = "This control is being wired to a Conform field by hand. Use the conform-* variant and pass field={fields.x}: Checkbox -> ConformCheckbox, Select -> ConformSelect, NumberField -> ConformNumberField, DatePicker -> ConformDatePicker, TextField/Input -> ConformField. For controls with no variant, spread getInputProps(field) rather than picking metadata off one property at a time. See https://ui-lib.quebi.de/rules/bind-fields-through-conform",
    severity = "error"
  )
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# bind-fields-through-conform — candidates for review
rg -n -g '*.{tsx,jsx}' \
  -g '!components/ui/**' \
  -g '!src/components/**' \
  "name=\\{[a-zA-Z]+\\.[a-zA-Z]+\\.name\\}"

Claiming an exception that is not a path

biome

One exception on this rule is a judgement call, so it cannot be a path. Biome's suppression syntax has a slot for the reason — fill it, because that note is what makes the carve-out reviewable instead of invisible.

{/* biome-ignore plugin: A control whose value never leaves the browser — A table filter, a search box that drives a client-side query, a disclosure toggle — these are component state and have no Conform field to bind to. */}

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

Exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

Your copy of the ui-lib component source (components/ui/**)
The conform-* variants are where getInputProps is called and the metadata is spread onto a control. That is the wrapping this rule asks you to use, not a violation of it.
A control whose value never leaves the browser
A table filter, a search box that drives a client-side query, a disclosure toggle — these are component state and have no Conform field to bind to. useState is right there. The rule is about controls that are part of a form's submitted value.

Scope and enforcement

Applies to
  • app/**/*.{tsx,jsx}
  • src/**/*.{tsx,jsx}
Enforced by
lint