rule 12 — tier 5

Gate lastResult on an idle navigation

Pass lastResult only while the navigation is idle — `navigation.state === "idle" ? lastResult : null`. Conform re-reports the result on every render, so an ungated one is applied before React Router has finished revalidating the loader data behind it.

errorlintgate-last-result-on-idle-navigation

forms

Validation is yours. Wiring is the library's.

what this catches

An agent wires the action result back with `useForm({ lastResult })`, because that is the shape every tutorial shows and it visibly works. What it skips is the one-line guard the framework integration documents, and the bug it leaves behind only appears on a form whose defaults come from a loader.

why

Conform re-applies lastResult on every render, not once per submission. `useForm` runs its update in a layout effect with no dependency array, so the effect fires on every render and reports the result again whenever its identity has changed. That is not a leak — it is what makes a second server rejection render — but it means the timing of *when* the result is applied is the caller's problem.

React Router revalidates loader data after an action, and the two do not finish together. If the form's defaultValue comes from a loader and the action resets the form, Conform applies the reset against the loader data it can see now, which is still the old data — so the form snaps back to the previous default value and then sits there looking authoritative. Gating on `navigation.state === "idle"` holds the result until the revalidation has landed. This is what conform.guide's Remix integration does, and the reason it gives.

The guard is cheap and the failure is not. A form that resets to a stale default after a successful save is a data-loss bug wearing the costume of a UI glitch: the user sees their old values back on screen, retypes them, and submits again. `fetcher.state` is the same guard for a form submitted through a fetcher.

What this rule is *not* about is stale errors surviving a redirect. `useForm` guards its own report with `options.lastResult &&`, so a null or undefined result is never re-reported and a Post/Redirect/Get is safe without any of this.

wrong / right

The result handed straight to useForm

don't

const lastResult = useActionData<typeof action>()
const [form, fields] = useForm({
  lastResult,
  onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
})

do

const lastResult = useActionData<typeof action>()
const navigation = useNavigation()
const [form, fields] = useForm({
  lastResult: navigation.state === "idle" ? lastResult : null,
  onValidate: ({ formData }) => parseWithValibot(formData, { schema: signupSchema }),
})

Verbatim from conform.guide's Remix integration, down to the null rather than undefined. While the navigation is anything but idle, Conform is handed nothing and leaves the form alone.

The same form submitted through a fetcher

don't

const fetcher = useFetcher<typeof action>()
const [form, fields] = useForm({ lastResult: fetcher.data })

do

const fetcher = useFetcher<typeof action>()
const [form, fields] = useForm({
  lastResult: fetcher.state === "idle" ? fetcher.data : null,
})

A fetcher has its own state, and its own revalidation to wait for. The guard is the same shape; only the thing being asked changes.

A type argument does not change the question

don't

const lastResult = useActionData<typeof action>()
const [form, fields] = useForm<SignupSchema>({ lastResult })

do

const lastResult = useActionData<typeof action>()
const navigation = useNavigation()
const [form, fields] = useForm<SignupSchema>({
  lastResult: navigation.state === "idle" ? lastResult : null,
})

useForm<Schema>({ … }) is the call Conform's docs lead with, and the race it runs is the one above. The check read only the untyped spelling until task #163, which is why a real form could carry an ungated lastResult and lint clean.

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: The check reads the guard textually: any lastResult expression mentioning navigation.state or fetcher.state satisfies it, including one that mentions it and gets the comparison backwards. It reads options written inline only — useForm(options) with the object in a variable is invisible, the same blind spot the tier-3 rule documents; a type argument is not one of them, as useForm<Schema>({ … }) is read exactly like useForm({ … }) — and it says nothing about forms that pass no lastResult at all, which is tier 3's business.

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/gate-last-result-on-idle-navigation.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.

// Gate lastResult on an idle navigation
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/gate-last-result-on-idle-navigation.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/gate-last-result-on-idle-navigation
//
// 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}",
//           "!**/*.stories.{tsx,jsx}",
//           "!**/*.examples.{tsx,jsx}"
//         ],
//         "plugins": [
//           "./ui-lib-rules/gate-last-result-on-idle-navigation.grit"
//         ]
//       }
//     ]
//   }

language js;

JsCallExpression(
  callee = `useForm`,
  arguments = JsCallArguments(args = [$options])
) as $call where {
  $options <: JsObjectExpression(),
  $options <: contains bubble or {
    JsPropertyObjectMember(name = $name, value = $value) where {
      $name <: `lastResult`,
      $value <: not contains `navigation.state`,
      $value <: not contains `fetcher.state`
    },
    JsShorthandPropertyObjectMember(name = $name) where { $name <: `lastResult` }
  },
  register_diagnostic(
    span = $call,
    message = "lastResult is applied on every render, so an ungated one lands before React Router has revalidated the loader data behind the form — a form with loader defaults snaps back to its previous value after a reset. Pass lastResult: navigation.state === 'idle' ? lastResult : null (or fetcher.state for a fetcher). See https://ui-lib.quebi.de/rules/gate-last-result-on-idle-navigation",
    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.

# gate-last-result-on-idle-navigation — candidates for review
rg -n -g '*.{jsx,tsx}' \
  -g '!**/*.stories.{tsx,jsx}' \
  -g '!**/*.examples.{tsx,jsx}' \
  "lastResult"

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, gate-last-result-on-idle-navigation.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/gate-last-result-on-idle-navigation: A form whose defaults do not come from a loader — The race is between the action result and the revalidated loader data.

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

Gallery, story and example files
A rendered example has no route action and no navigation to be idle, so there is nothing to gate. The conform-* examples in this repo are demonstrations of a binding, not a template for a routed form.
A form whose defaults do not come from a loader
The race is between the action result and the revalidated loader data. With no loader behind the form there is no second source to disagree with, and the guard is defensive rather than load-bearing — write it anyway if the form might grow one, but this is the honest scope of the failure.

scope and enforcement

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