Forms (new TanStack forms)
Every new form is useAppForm (~/hooks/forms/useAppform) over a zod schema. Formik +
yup is migration debt: never start a new form there, even next to one.
- Converting an existing Formik form →
migrate-formik-to-tanstack(owns the yup→zod mapping and the parity audit). - The form lives in a drawer or dialog → also read
lago-drawers/lago-dialogs, which own the surrounding hook.
Everything below is on top of .agents/docs/typescript-conventions.md, which a form hits
constantly: explicit return types, no nested ternaries, and a renderX() helper above the
return instead of conditionals nested inside the JSX.
The skeleton
The schema file carries the schema, the inferred type, the default values and the
form→API mapper (approveQuote/validationSchema.ts is the shortest complete example):
// src/pages/<feature>/validationSchema.ts
export const featureValidationSchema = z.object({
name: z.string().min(1, { message: 'text_<key>' }),
email: zodRequiredEmail,
})
export type FeatureFormValues = z.infer<typeof featureValidationSchema>
export const featureDefaultValues: FeatureFormValues = { name: '', email: '' }
export const buildFeatureInput = (values: FeatureFormValues): FeatureInput => ({ ... })
const FEATURE_FORM_ID = 'feature-form'
const form = useAppForm({
defaultValues: existing ? mapFromApi(existing) : featureDefaultValues,
validationLogic: revalidateLogic(),
validators: { onDynamic: featureValidationSchema },
onSubmit: async ({ value }) => {
await mutate({ variables: { input: buildFeatureInput(value) } })
},
onSubmitInvalid({ formApi }) {
scrollToFirstInputError(FEATURE_FORM_ID, formApi.state.errorMap.onDynamic || {})
},
})
const handleSubmit = (event: React.FormEvent): void => {
event.preventDefault()
form.handleSubmit()
}
return (
<form id={FEATURE_FORM_ID}
<form.AppField name="name">
{(field) => <field.TextInputField label={translate('text_<key>')} />}
</form.AppField>
<form.AppForm>
<form.SubmitButton>{translate('text_<key>')}</form.SubmitButton>
</form.AppForm>
</form>
)
Non-negotiables
| Rule | Why |
|---|---|
Always pass validationLogic: revalidateLogic() |
Omitted, TanStack falls back to defaultValidationLogic, which never runs onDynamic — the schema is silently skipped and every value passes |
| Submit-first: the form is never invalid before the first submit attempt | revalidateLogic() is mode: 'submit' + modeAfterSubmission: 'change'. Submit stays enabled until then, whatever the field type |
| A field component publishes the value; the schema decides if it is acceptable | A component that withholds a rejected value leaves the input and the form state disagreeing — a shipped bug. Only a value with no representation at all (unparseable date) may be withheld |
Bare <form.SubmitButton> inside <form.AppForm> |
It subscribes to canSubmit + isSubmitting and gets the spinner for free. canSubmit excludes isDirty by design — never add a !isDirty gate |
await every async call inside onSubmit |
isSubmitting flips back when onSubmit's own promise resolves, so a dropped promise kills the spinner before the mutation settles |
useStore(form.store, (s) => …) for anything read in the render |
form.state.* is a passive read: no subscription, no re-render. It is fine inside event handlers, which only need a snapshot. Import useStore from @tanstack/react-form |
id on the <form> + onSubmitInvalid → scrollToFirstInputError |
The helper queries #${formId} input. Wire it whenever the form can be taller than the viewport |
Side-effect on a value change → listeners={{ onChange }} on form.AppField |
Not useStore + useEffect. useStore is for reading a value, not reacting to it |
| Set a whole array, never a bracket index on a possibly-undefined one | form.setFieldValue('rules[0]', x) on an undefined base builds {0: x}, not an array |
Fields: what each component stores
form.AppField + the registered field.* components only — never a raw input, never a
hand-wired <Button type="submit">. Write the schema against the stored shape below,
not against the payload the API wants: a mismatch fails silently, leaving submit disabled
with no error and no request (a shipped regression).
| Component | Stored value |
|---|---|
TextInputField |
string — unless beforeChangeFormatter includes 'int', which stores number | '', plus a transient '-' while a negative number is being typed (pair it with 'positiveNumber', which strips the sign first, to rule that out) |
AmountInputField |
string | number | undefined — 'int' is auto-pushed for 0-decimal currencies (JPY, KRW…), so an amount flips shape with the currency |
ComboBoxField |
string | undefined |
MultipleComboBoxField |
whole options, { value, label, … }[] — schema z.array(z.looseObject({ value: z.string() })), map to ids in onSubmit, seed defaults as options |
CurrencyPickerField |
CurrencyEnum | undefined |
DatePickerField |
ISO string | undefined |
SwitchField, CheckboxField |
boolean |
RadioField |
string |
RadioGroupField |
string | number | boolean |
ButtonSelectorField |
the option's own value type |
name + code fields → NameAndCodeGroup (~/components/form/NameAndCodeGroup), which
auto-derives the code until the user edits it. Loading an edit form → FormLoadingSkeleton
(~/styles/mainObjectsForm).
No registered component fits → add a <Input>FieldForTanstack.tsx next to the design-system
input and register it in useAppform.ts, rather than wiring the raw input into the form. The
wrapper reads its field through useFieldContext<StoredType>() and its message through
useFieldError(), so the stored shape becomes explicit and every consumer inherits the same
error behaviour.
Schema
- Reuse before writing:
src/formValidation/zodCustoms.ts(zodRequiredEmail,zodRequiredUrl,zodRequiredPassword,zodOptionalUrl,zodHost,zodMultipleEmails…). Keep the shared validator even when its message differs from the one the design asks for — consistent copy across the app wins. A validator that generalises beyond this form belongs inzodCustoms.ts. - Location: colocated
validationSchema.tsnext to the form.src/formValidation/is for schemas two or more forms share (subscriptionFormSchema,planFormSchema,chargeSchema,metadataSchema). - Messages are translation keys. Every issue without an explicit message falls back to
DEFAULT_ZOD_ERROR_MESSAGE(initializeZod.ts), so never writemessage: ''— Zod v4 replaces it with its own "Invalid input", which then renders raw. Wrappers translate the key without variables: for{{min}}-style copy, emit the key and let the component translate it througherrorOverride. - Dates:
addUnsupportedDateIssue(ctx, value, path, floor?)fromzodCustomsrejects anything below the 1970 floor (MIN_SUPPORTED_DATE). It returns whether it added an issue, so a following rule canreturninstead of stacking a second message. Skip it only where an existing rule already rejects the floor with better copy (must-be-future). superRefinemust never throw: guard withArray.isArrayand optional chains. A throw inside validation lands afterisSubmitting = trueand the button spins forever — infinite spinner + zero errors + no request means a throwing validator.
Errors
Field display knobs (useFieldError): silentError hides the message, displayErrorText: false keeps only the red state, showOnlyErrors allowlists messages (TextInputField),
and errorOverride takes full control — a string replaces the message, false suppresses
it (TextInputField, AmountInputField, DatePickerField).
Server and GraphQL errors are set imperatively, in two shapes that are not interchangeable:
// One field, from a mutation result — the errorMap is keyed by field path
form.setFieldMeta('code', (meta) => ({
...meta,
errorMap: { ...meta.errorMap, onDynamic: { message: 'text_<key>' } },
}))
// Several fields at once — nested under `fields`, each value an OBJECT, never a string
formApi.setErrorMap({
onDynamic: { fields: { email: { message: translate('text_<key>'), path: ['email'] } } },
})
Prefer setErrorMap for a server error: it stamps the error as form-owned, so the next
validation drops it and submit re-enables on its own. A setFieldMeta error reads as
field-owned, and on these forms — schema on the form, no validator on the field — nothing
ever clears it, so submit stays disabled for the life of the form.
For a duplicate code that is applyExistingCodeError(formApi)
(~/core/form/existingCodeError), called in the mutation catch on
LagoApiError.ValueAlreadyExist. It needs no clearing counterpart; do not add a listener
to clear it.
Validator-produced errors live directly on errorMap.onDynamic keyed by field path; the
.fields sub-shape exists only for errors set manually. Reading section validity for
unmounted fields therefore goes through the form error map, not fieldMeta.
Splitting a form
withForm |
withFieldGroup |
|
|---|---|---|
| For | a section of one specific form | a field group reused across forms |
| Call | <Section form={form} /> |
<NameAndCodeGroup form={form} fields={{ name: 'name', code: 'code' }} /> |
render receives |
form |
group, scoped to the mapped fields |
Both come from ~/hooks/forms/useAppform and take { defaultValues, props, render }. The
fields mapping is what lets a group bind to differently-named fields on the host form,
so a group's own defaultValues keys are its internal names, never the host's.
A form inside a dialog is independent of its parent form: its own useAppForm, values
flowing out through a callback, wired with dialogFormProps(FORM_ID, form).
Tests
Run /make-tests for the suite itself. Two rules specific to forms:
- One select-then-submit case per combobox / multi-select asserting the mutation variables — that is what catches a schema written against the wrong stored shape.
- Unit-test the schema directly when it carries cross-field or conditional rules; a
validationSchema.test.tsnext to it is the convention.
A test rendering a form inside a drawer or dialog needs the import.meta mock — see
lago-drawers / lago-dialogs.
Reference implementations
CreateSubscription.tsx + SubscriptionInformationFormSection.tsx (multi-section,
withForm), CreateCoupon.tsx (conditional sections, server error on code),
EditInvoiceItemDescriptionDialog.tsx (small form in a FormDialog, via
dialogFormProps), approveQuote/validationSchema.ts (schema + type + defaults +
mapper), src/components/wallets/tanstackForm/ (drawer forms), CreatePricingUnit.tsx
(scrollToFirstInputError).
Not a model to copy: EditFeeBillingPeriod.tsx drives a raw DatePicker inside
form.AppField and carries a message: '', both of which this guide rules out. It works,
but reading it as a template reproduces two things a new form should not do.