Skip to content

0016: Forms use TanStack Form

Browser form state in every app lives in TanStack Form, through one binding per app

  • Status: Accepted
  • Date: 2026-10-15
  • Decision maker: Trevor Gerhardt

Context

Concourse and MIDOT have forms that users type into. Each app kept the state of these forms in its own way.

  • The MIDOT property value tool (PRD-0010) must keep the typed text, show field errors while the user types, keep Save unavailable while a field is invalid, show unsaved changes, and ask before the user leaves with unsaved changes. The first version held this state in separate React state values and a key that remounted the form. Review found several defects in how these values stayed in step.
  • The MIDOT sign-in pages read FormData by hand and kept field errors in React state.
  • Concourse forms submit to Server Actions and checked fields only with HTML attributes. The browser shows those messages in its own words and styles. A type-to-confirm delete could be submitted with the wrong text, and only the server refused it.

The Michigan React wrappers that MIDOT uses set every property again on each render. A field that does not receive the current text resets, so each MIDOT field must be controlled.

Decision

Browser forms with fields that a user edits keep their field text, field validation, and submit state in TanStack Form. Each app has one binding between TanStack Form and its design system:

  • Concourse uses apps/concourse/components/app-form.tsx. Its useAppForm hook gives field components that render the @concourse/ui fields, and an ActionForm that runs the field validators before it submits to the form's action. Server Actions still receive FormData, still redirect with a notice, and still check every value again. PendingSubmitButton still shows the pending state.
  • MIDOT uses useForm with controlled Michigan fields. Each field passes the form's current text as value and sends the text from its somInput event to field.handleChange.

The rules are the same in both apps:

  • Field validators run on change and on submit. They repeat the checks that the server makes, in simple words, so that the user sees the problem next to the field. The server stays the authority.
  • After a successful submit, the form shows its new default values. MIDOT calls form.reset with the saved values. A Concourse Server Action redirects, and the page renders the form again with its default values.
  • Conversion from field text to stored values stays in plain functions, so unit tests cover it without a browser.

These forms stay without TanStack Form, because they have no field that a user types into or the URL holds their state:

  • forms that have only a submit button and hidden fields;
  • a row form with one select that submits at once, such as a role change;
  • the Concourse audit filter, which is a GET form, so the query string holds its state.

Dialog state, notices, and data loading stay outside TanStack Form. TanStack Query is not part of this decision. Concourse reads data in Server Components, and MIDOT has one resource on one route that a Router loader reads.

Consequences

  • One library owns form state in all apps, so a new form does not need its own state rules.
  • @tanstack/react-form is a catalog dependency. Its type declarations import React types without declaring them, so pnpm-workspace.yaml adds @types/react to it, like the Phosphor entry.
  • Concourse forms with fields are Client Components. Their pages stay Server Components and pass the Server Action and the default values as props.
  • Field checks need JavaScript. Without JavaScript, a Concourse form that posts to a Server Action still submits, and the server refuses bad values with a notice.
  • TanStack Form changes its minor versions often. An upgrade needs the browser tests of both apps.

References