rule 13 — tier 6

An intent button submits — never type="button"

getButtonProps supplies name, value, form and formNoValidate, but never type. A Conform intent button has to be type="submit": with type="button" no submit event fires and the insert, remove or reorder is dropped in silence.

errorlintintent-buttons-must-not-be-type-button

forms

Validation is yours. Wiring is the library's.

what this catches

An agent writes `type="button"` on anything that is not the form's primary action, because that is the habit that stops stray buttons submitting a form. On a Conform intent button it is the one attribute that has to be the other value, and getButtonProps does not supply it — so the rule of thumb quietly disables the feature.

why

The intent travels on the submitter, and only a submitter has one. `form.insert.getButtonProps({ name: fields.items.name })` returns `{ name, value, form, formNoValidate }` — everything except the type, which comes from your JSX. Conform reads the intent back off `event.submitter`, and its own `isSubmitter` check is `element.type === 'submit'`. A `type="button"` control never fires a submit event at all, so `form.onSubmit` does not run, the intent is never parsed, and the row is never added.

There is no error to see. Nothing throws, nothing warns, nothing appears in the console: the button presses, the ripple runs, and the list does not change. (Force the same props onto a submission another way and Conform's getFormData throws `The submitter must be an input or button element with type submit` — which is the diagnostic this rule exists to give you before you get there.)

react-aria's Button makes the trap easier to fall into, because its default type is `button`. A quebi `<Button {...form.insert.getButtonProps(...)}>` with no type attribute at all is broken in exactly the same way as one that says so — this check can only see the explicit case, which is why the rule is written as "an intent button submits" and not as "do not write type=button".

Calling the operation as a function is the other correct shape and needs no attribute at all: `form.insert({ name: fields.items.name })` from an onPress handler builds its own submitter internally with `document.createElement('button')`, which defaults to type=submit. Reach for it when the control cannot be a submit button; do not reach for the props and then take the submit away.

use this instead

<Button>
  • Button from @/components/buttonkeep the component, change the attribute: type="submit" plus the getButtonProps spread

react-aria's Button defaults to type="button", so an intent button has to say type="submit" out loud even when no other button in the form does.

wrong / right

An intent button that never submits

don't

<Button
  type="button"
  intent="outline"
  {...form.insert.getButtonProps({ name: fields.items.name })}
>
  Add item
</Button>

do

<Button
  type="submit"
  intent="outline"
  {...form.insert.getButtonProps({ name: fields.items.name })}
>
  Add item
</Button>

One word. The spread already carries formNoValidate, so submitting to add a row does not trip the form's own validation on the way.

When the control really cannot submit

don't

<MenuItem
  type="button"
  {...form.remove.getButtonProps({ name: fields.items.name, index })}
>
  Remove
</MenuItem>

do

<MenuItem onAction={() => form.remove({ name: fields.items.name, index })}>
  Remove
</MenuItem>

Called as a function, Conform builds its own submitter — a real button with type=submit — appends it, clicks it, and removes it again. The intent arrives without the control having to be a submit button at all.

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: It sees only the explicit attribute. The worse case — a react-aria Button with the spread and no type at all, which defaults to type="button" and is just as broken — is invisible to a syntactic check, so the ripgrep line is the wider net: every getButtonProps call is worth a look at the type next to it. The spread is matched textually, so an intent taken off the form into a local variable first escapes it.

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/intent-buttons-must-not-be-type-button.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.

// An intent button submits — never type="button"
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/intent-buttons-must-not-be-type-button.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/intent-buttons-must-not-be-type-button
//
// 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}"
//         ],
//         "plugins": [
//           "./ui-lib-rules/intent-buttons-must-not-be-type-button.grit"
//         ]
//       }
//     ]
//   }

language js;

or {
  JsxOpeningElement(name = $el, attributes = $attrs),
  JsxSelfClosingElement(name = $el, attributes = $attrs)
} as $node where {
  $attrs <: contains JsxSpreadAttribute() as $spread,
  $spread <: r".*\.(?:insert|remove|reorder|reset|update|validate)\.getButtonProps\(.*",
  $attrs <: contains JsxAttribute(name = `type`, initializer = $init),
  $init <: r".*button.*",
  register_diagnostic(
    span = $node,
    message = "A Conform intent button has to be type='submit': getButtonProps supplies name, value, form and formNoValidate but never the type, and a type='button' control fires no submit event, so the insert/remove/reorder is dropped silently. Change it to type='submit', or call form.insert(...) from onPress instead of spreading the props. See https://ui-lib.quebi.de/rules/intent-buttons-must-not-be-type-button",
    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.

# intent-buttons-must-not-be-type-button — candidates for review
rg -n -g '*.{jsx,tsx}' \
  "getButtonProps\\("

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, intent-buttons-must-not-be-type-button.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/intent-buttons-must-not-be-type-button: A control that must not submit, dispatching the intent from its handler instead — If a button genuinely cannot be type="submit" — it sits inside another form's markup, or a parent owns submission — call form.insert(...) from the handler rather than spreading getButtonProps: Conform's dispatch builds its own submitter and the intent still arrives.

// 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.

A control that must not submit, dispatching the intent from its handler instead
If a button genuinely cannot be type="submit" — it sits inside another form's markup, or a parent owns submission — call form.insert(...) from the handler rather than spreading getButtonProps: Conform's dispatch builds its own submitter and the intent still arrives. Spreading the props and keeping type="button" is the one combination that silently does nothing, so if a case really needs it, suppress it on the line above with `// biome-ignore lint/plugin/intent-buttons-must-not-be-type-button: <reason>` and write the reason out.

scope and enforcement

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