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
FormDataby 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. ItsuseAppFormhook gives field components that render the@concourse/uifields, and anActionFormthat runs the field validators before it submits to the form'saction. Server Actions still receiveFormData, still redirect with a notice, and still check every value again.PendingSubmitButtonstill shows the pending state. - MIDOT uses
useFormwith controlled Michigan fields. Each field passes the form's current text asvalueand sends the text from itssomInputevent tofield.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.resetwith 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
GETform, 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-formis a catalog dependency. Its type declarations import React types without declaring them, sopnpm-workspace.yamladds@types/reactto 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.