rule 08 — tier 1

Bind fields through Conform, never by hand

Take the binding off the field metadata: the conform-* variant, which every submittable control now has, getInputProps (or useControl) for anything else, and getFormProps on the form element. Per-field useState and a hand-passed name are the failure mode.

errorlintbind-fields-through-conform

forms

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.

Twenty-nine of the library's components ship a Conform-bound variant — every control that produces a submittable value. That is deliberate: this rule used to send you to "bind it by hand" for most of the library, which is the path the rule itself calls the failure mode. What is left without a variant is a sub-part of another control (a ColorArea channel, a ListBox, a TagGroup) rather than a field of its own; bind those yourself with getInputProps, or with useControl when the control has no native form value. That is not a worse path — it is the same metadata, read explicitly.

Reading the metadata explicitly does not mean spreading getInputProps onto a react-aria control. The helper returns the DOM names — required, defaultChecked, min, max — and react-aria takes isRequired, defaultSelected, minValue, maxValue. A JSX spread skips excess-property checking, so the mismatched half is dropped by filterDOMProps with no type error, no React warning, and no attribute left in the DOM: a Switch renders off after a failed submit, a NumberField silently forgets the schema's bounds. Spread getInputProps onto a real <input>; onto a component, name the props.

use this instead

<Checkbox>
TextField / Input
  • ConformField from @/components/conform-fieldtext, email, and password fields
<NumberField>
<Select>
DateField / DatePicker
ColorPicker / ColorSwatchPicker
<DaySchedule>
<StoragePicker>
<Switch>
<RadioGroup>
<Textarea>
<CheckboxGroup>
  • ConformCheckboxGroup from @/components/conform-checkbox-groupa set of values under one name — ConformCheckbox binds a single boolean
<ComboBox>
<Slider>
  • ConformSlider from @/components/conform-slidersingle and range values alike — the name goes on the thumb
<SearchField>
  • ConformSearchField from @/components/conform-search-fieldthe query is part of a submitted form, not a client-side filter
<TimeField>
  • ConformTimeField from @/components/conform-time-fieldalways — react-aria renders no form control for a TimeField, so a bare name submits nothing
DateRangePicker / RangeCalendar
<Calendar>
  • ConformCalendar from @/components/conform-calendaran always-visible calendar — ConformDatePicker covers the popover case
<ColorField>
<TagField>
<InputOTP>
<ChoiceBox>
FileTrigger / DropZone
  • ConformFileTrigger from @/components/conform-file-triggeralways — FileTrigger has no name and filterDOMProps drops one silently
<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
Anything with no conform-* variant (a control you built, a sub-part like ColorArea or ListBox)
  • getInputProps from @conform-to/reactyou are rendering a real <input> — spread it there, not onto a react-aria component, whose prop names differ
  • useControl from @conform-to/react/futurethe control has no native form value — it returns value/checked/options/files/change/focus/blur and pairs with BaseControl, which renders the hidden input the control needs

Every library control that produces a submittable value now has a variant, so this is the path for your own components and for sub-parts of a control (a ColorArea binds two channels, a ListBox renders another control's options). 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-quebi-danger">{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 slider wired by hand

don't

const [volume, setVolume] = useState(50)

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

do

import { ConformSlider } from "@/components/conform-slider"

<ConformSlider field={fields.volume} label="Volume" maxValue={100} />

The hidden-input trick is the tell: the value lives in component state, so it does not come back after a failed submit and does not reset with the form. The variant also knows the thing the hand-written version gets wrong — the name belongs on the SliderThumb, which is where react-aria renders the real range input; a name on the Slider goes nowhere.

getInputProps spread onto a react-aria control

don't

<Switch {...getInputProps(fields.notify, { type: "checkbox" })}>
  Email notifications
</Switch>

do

import { ConformSwitch } from "@/components/conform-switch"

<ConformSwitch field={fields.notify} label="Email notifications" />

This one type-checks, renders, and is wrong. getInputProps returns defaultChecked and required — the DOM names — and react-aria's Switch takes neither, so filterDOMProps drops both: no TS error (JSX spread skips excess-property checking), no React warning, no stray attribute, and a switch that renders off after every failed submit. Spread getInputProps onto a real <input>. Onto a component, name the props — or use the variant that already has.

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: FileTrigger and DropZone are deliberately absent from the element list even though they have a variant: neither takes a `name`, so the hand-bound shape is a hidden input beside them, which this check cannot see. 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. The object handing over the property has to be spelled like a field — `field`, `props.field`, `fields.x`, `fields.address.street` — so an ordinary `person.name` or `file.name` on a control is not a finding; until task #194 it was, and an error whose only way out was a suppression at every call site. What is bought with that is what is lost: a field reached through an alias (an item out of `getFieldList()`, a destructured `const { name } = fields.terms`) is spelled like a domain object and goes unreported. Nor can it 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 load it from an `overrides` entry — the one in the file's own header, which lists the paths the rule applies to (app/**/*.{tsx,jsx}, src/**/*.{tsx,jsx}) and the paths its documented exceptions carve back out. Not from the top-level `plugins`: a plugin listed there runs against every file you lint, and an override can add one but cannot take one away. If your project keeps its components somewhere else, edit the `includes` to match.

// 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
//
// This file says what the rule is, not where it applies. A plugin listed in
// the top-level `plugins` runs against every file Biome lints, so scope it
// with an `overrides` entry — Biome resolves `includes` against the project
// root, which is the one thing a $filename regex in here could not do:
//
//   {
//     "overrides": [
//       {
//         "includes": [
//           "app/**/*.{tsx,jsx}",
//           "src/**/*.{tsx,jsx}",
//           "!components/ui/**",
//           "!src/components/**"
//         ],
//         "plugins": [
//           "./ui-lib-rules/bind-fields-through-conform.grit"
//         ]
//       }
//     ]
//   }

language js;

or {
  JsxOpeningElement(name = $el, attributes = $attrs),
  JsxSelfClosingElement(name = $el, attributes = $attrs)
} as $control where {
  $el <: r"^(?:Calendar|Checkbox|CheckboxGroup|ChoiceBox|ColorField|ColorPicker|ColorSwatchPicker|ComboBox|DateField|DatePicker|DateRangePicker|DaySchedule|InputOTP|MultipleSelect|AsyncMultipleSelect|AsyncSelect|NumberField|RadioGroup|RangeCalendar|SearchField|Select|Slider|StoragePicker|Switch|TagField|Textarea|TextField|TimeField)$",
  $attrs <: contains or {
    `$meta.name`,
    `$meta.errors`,
    `$meta.initialValue`,
    `$meta.errorId`,
    `$meta.formId`
  } where {
    $meta <: r"^(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*(?:field|fields\.[A-Za-z0-9_$]+(?:\.[A-Za-z0-9_$]+)*)$"
  },
  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}: every control that submits a value has one — Checkbox -> ConformCheckbox, Switch -> ConformSwitch, Select -> ConformSelect, RadioGroup -> ConformRadioGroup, Textarea -> ConformTextarea, Slider -> ConformSlider, TimeField -> ConformTimeField, FileTrigger -> ConformFileTrigger, TextField/Input -> ConformField. For a control you built yourself, spread getInputProps(field) onto its real <input>, or use useControl from @conform-to/react/future when it has none — do not pick 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 '*.{jsx,tsx}' \
  -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. The rule is named by its plugin's file name, bind-fields-through-conform.grit — a suppression that names this rule quiets this rule and no other plugin on the same line, and if the file is renamed Biome reports the comment as having no effect rather than guessing. `lint/plugin` on its own is valid too and quiets every plugin rule on the node at once; name the rule instead.

// biome-ignore lint/plugin/bind-fields-through-conform: 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.

// Between JSX children, where a comment has to be an expression, the same
// line is written {/* biome-ignore … */}.

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 the metadata is read off the field and put 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