Formik to TanStack Form Migration Skill
Target form to migrate: $ARGUMENTS
Important: If no path was provided above (empty or missing), use the AskUserQuestion tool to ask the user for the path to the Formik form they want to migrate before proceeding.
This skill guides the migration of React form components from Formik to TanStack Form, following the established patterns in this codebase.
Building a form that has no Formik ancestor is a different job: read
lago-forms, which owns the conventions for new forms. This skill covers only what a migration adds on top — the yup→zod mapping, the value-shape audit and the parity check.
Prerequisites
Before starting, gather context by reading these reference files:
Simple Forms
- Hook Pattern:
src/hooks/forms/useAppform.ts- The customuseAppFormhook - Validation Schema Example:
src/pages/auth/signUpForm/validationSchema.ts - Form Component Example:
src/pages/settings/roles/roleCreateEdit/RoleCreateEdit.tsx - Simple Form with Table:
src/pages/developers/ApiKeysForm.tsx- Form with permissions table
Medium Complexity Forms
- Coupon Form:
src/pages/CreateCoupon.tsx- Conditional fields, plan/metric limits, listeners pattern - Dialog with Independent Form:
src/pages/createCoupon/dialogs/AddBillableMetricToCouponDialog.tsx- Dialog containing its own TanStack form
Complex Forms (with sub-components)
- Complex Form Example:
src/pages/createCustomers/CreateCustomer.tsx- Main form with sub-components - Complex Validation Schema:
src/pages/createCustomers/formInitialization/validationSchema.ts- Nested Zod schemas with refinements - Sub-component with withForm:
src/pages/createCustomers/customerInformation/CustomerInformation.tsx- HOC pattern
Reusable Field Groups
- NameAndCodeGroup:
src/components/form/NameAndCodeGroup/NameAndCodeGroup.tsx- Reusable name+code field group usingwithFieldGroup
Migration Steps
Phase 1: Pre-Migration Analysis
Step 1.1: Analyze the Current Form Structure
- Read the target form file completely
- Identify:
- Form fields and their types
- Current Formik configuration (
useFormikor<Formik>) - Submit handler logic
- Field components used (
TextInputField,Checkbox, etc.) - Any
formikPropsusage - Sub-components that receive
formikProps - Server-side error handling — search for
setFieldError,setErrors,setStatusin theonSubmithandler. These set errors on fields after a mutation fails (e.g., API returns NotFound, ValueAlreadyExist, UrlIsInvalid). Each one MUST be migrated toformApi.setErrorMapin the TanStack form
Step 1.2: Deep Validation Analysis (CRITICAL)
This step is critical. Document ALL validations before proceeding.
⛔ CRITICAL — Formik does NOT validate raw values (
prepareDataForValidation)Formik runs Yup on
prepareDataForValidation(values), which recursively converts every empty string ('') toundefined(arrays and nested objects included), and passes the PREPARED values as the Yupcontexttoo. A literal Yup→Zod translation silently diverges on every''-sensitive check:
Number('') === 0(passes!isNaN) whileNumber(undefined)isNaN(fails it) → "at least one of X/Y" rules stop firing on emptied fieldsmin <= maxcross-checks run against0instead of being skipped- optional numeric fields (
yup.number().min().max()) accepted an emptied input (''→undefined→ not required); a raw-''port wrongly rejects itRule: in the Zod schema, treat
''as ABSENT wherever the Yup rule relied on presence/isNaN/numeric casts. Use a tiny helper and apply it inside each check:const prepared = <T,>(value: T): T | undefined => value === ('' as unknown as T) ? undefined : valueReference implementation:
src/pages/wallet/formInitialization/validationSchema.ts.
⛔ CRITICAL — the schema must validate what the WRAPPER stores, not what Formik stored
Formik forms often bind raw components manually, with an
onChangethat TRANSFORMS the value before it reaches form state:// Formik: onChange is a shape ADAPTER — options in, bare id strings stored <MultipleComboBox name="sectionIds" => formikProps.setFieldValue('sectionIds', options.map(({ value }) => value))} />The registered
field.*Fieldwrappers callfield.handleChange(rawComponentValue)— they store the component's NATIVE value, and the manual adapter silently dies in the migration. Port the Yup schema 1:1 and it now validates a shape that no longer exists. Zod rejects on every change,canSubmitstaysfalseforever: submit button disabled, no visible error, no network request (the MultipleComboBox shape regression, lago-front#3932 → #4067). Seeding is broken the same way:defaultValueswritten in the OLD shape (bare ids) don't match the combobox options, so an existing selection renders no tags.The Step 3.3 differential Yup↔Zod audit does NOT catch this — old and new schema agree with each other while both disagree with the new runtime value. Value-shape parity is a separate check from validation-semantics parity.
Rule — for EVERY field being migrated:
- Grep the Formik JSX for custom
onChange/setFieldValuetransforms — each one is a shape adapter that the registered wrapper will NOT reproduce.- Read the wrapper (
src/components/form/**/*ForTanstack.tsx) and note the type it passes tofield.handleChange/ expects inuseFieldContext<...>().- Write the Zod schema against the WRAPPER's shape; move id-extraction/mapping into
onSubmit(the API contract doesn't change).- Seed
defaultValuesin the wrapper's shape too (e.g. build{ value, label }options from the existing selection).- Derive
FormValueswithz.infer<typeof schema>so the schema and the type cannot drift.Known wrapper shapes:
Wrapper Stores in form state Schema MultipleComboBoxFieldWHOLE options: MultipleComboBoxData[]({ value, label, … })z.array(z.looseObject({ value: z.string() }))+ map to ids inonSubmitComboBoxFieldthe option's valueasstring | undefined(clearing setsundefined)requiredness is a BUSINESS rule, not UI clearability: required → z.string().min(1)(a cleared field correctly fails); optional →z.string().optional()TextInputField(int)number | ''(see Pattern 11)z.union([z.number(), z.literal('')])+.refine((v) => v !== '')when required (Pattern 11)Reference:
src/components/customers/editCustomerInvoiceCustomSections/validationSchema.ts(the shape-regression fix) andCreateQuotefor the MultipleComboBox convention.
Locate validation sources - Search for:
// Yup schema (most common) validationSchema: yupSchema // Inline validate function validate: (values) => { ... } // Field-level validation <Field validate={(value) => ...} /> // validateOnBlur, validateOnChange settingsCreate Validation Mapping Table:
Field Name Current Validation (Formik/Yup) Zod Equivalent Notes name yup.string().required()z.string().min(1)email yup.string().email().required()z.string().email().min(1)age yup.number().min(18).max(100)z.number().min(18).max(100)password yup.string().min(8).matches(/[A-Z]/)z.string().min(8).regex(/[A-Z]/)Identify Cross-Field Validations:
// Example: password confirmation .test('passwords-match', 'Passwords must match', function(value) { return this.parent.password === value }) // Maps to Zod .refine(): .refine((data) => data.password === data.confirmPassword, { message: 'Passwords must match', path: ['confirmPassword'], })Document Conditional Validations:
// Example: required only if another field has value .when('hasAddress', { is: true, then: yup.string().required(), }) // Maps to Zod .refine(): .refine((data) => !data.hasAddress || data.address, { message: 'Address is required', path: ['address'], })Check for Custom Validation Messages:
- Note all custom error messages
- These must be preserved in Zod schema
Identify Async Validations (if any):
// Formik async validation .test('unique-email', 'Email already exists', async (value) => { const exists = await checkEmailExists(value) return !exists })Note: Async validations require special handling in TanStack Form.
Step 1.3: Create Validation Migration Plan
Before writing any code, create a plan document:
## Validation Migration Plan: [FormName]
### Validation Sources Found
- [ ] Yup validationSchema: `path/to/schema.ts`
- [ ] Inline validate function: line XX
- [ ] Field-level validations: lines XX, YY
- [ ] No explicit validation (form relies on required HTML attributes)
### Field Validations
| Field | Yup Validation | Zod Equivalent | Custom Message |
| ----- | -------------- | -------------- | -------------- |
| ... | ... | ... | ... |
### Field Value-Shape Map (REQUIRED — do not skip, see the MultipleComboBox shape regression below)
One row PER FIELD. "Formik transform" = any custom `onChange`/`setFieldValue` mapping.
| Field | Formik stored shape | Formik transform? | TanStack wrapper | Wrapper stored shape | Zod shape |
| ----- | ------------------- | ----------------- | ---------------- | -------------------- | --------- |
| ... | ... | ... | ... | ... | ... |
**If any row's "Formik stored shape" ≠ "Wrapper stored shape": schema follows the wrapper,
`onSubmit` maps back to the API shape, `defaultValues` seed in the wrapper shape.**
### Cross-Field Validations
| Fields Involved | Yup Logic | Zod .refine() Logic |
| --------------- | --------- | ------------------- |
| ... | ... | ... |
### Conditional Validations
| Condition | Affected Fields | Zod Implementation |
| --------- | --------------- | ------------------ |
| ... | ... | ... |
### Async Validations
| Field | Current Implementation | TanStack Approach |
| ----- | ---------------------- | ----------------- |
| ... | ... | ... |
### Server-Side Error Handling (CRITICAL — easy to miss)
Search for `setFieldError`, `setErrors`, `setStatus` in the onSubmit handler. These are server-side errors set AFTER a mutation response and must be migrated to `formApi.setErrorMap`.
| Formik Call | GQL Error | Target Field | Error Message Key | TanStack `setErrorMap` |
| --------------------------------------- | ---------- | ------------ | ----------------- | ---------------------- |
| `formikBag.setFieldError('email', ...)` | `NotFound` | `email` | `text_xxx` | See Pattern 4 below |
**If no `setFieldError`/`setErrors`/`setStatus` calls are found, write "None" and move on.**
### Submit Button Disabled Logic
Current: `disabled={!formikProps.isValid || !formikProps.dirty || loading}`
TanStack: `form.SubmitButton` handles validity (`canSubmit`) + `isSubmitting` automatically.
**⛔ DO NOT re-introduce a `dirty` gate.** The Formik forms commonly disabled submit on a pristine form (`!dirty`). **Do not preserve this.** The TanStack convention in this codebase is: **the submit button is enabled by default and only becomes disabled when the form has validation errors** (handled automatically by `canSubmit`). Gating on `!isDirty` is wrong — it blocks submitting a dialog when the user hasn't changed anything (e.g., re-confirming a pre-filled value), which diverges from every other migrated TanStack form. Just use a bare `<form.SubmitButton>` and let `canSubmit` do the gating.
```tsx
// ❌ WRONG — do not gate on dirty
<form.Subscribe selector={(state) => state.isDirty}>
{(isDirty) => <form.SubmitButton disabled={!isDirty}>{label}</form.SubmitButton>}
</form.Subscribe>
// ✅ CORRECT — enabled by default, disabled only on validation errors (canSubmit)
<form.SubmitButton>{label}</form.SubmitButton>
(Only pass disabled for a genuinely external concern, e.g. an unrelated loading state — never for dirtiness.)
Validation Timing
- validateOnMount: [true/false]
- validateOnChange: [true/false]
- validateOnBlur: [true/false]
---
### Phase 2: Implementation
#### Step 2.1: Create Validation Schema
##### Step 2.1.0: Check for Existing Shared Validators (MANDATORY)
**Before writing any new Zod schema, check `src/formValidation/zodCustoms.ts` for reusable validators.**
This file contains shared validators like `zodRequiredEmail`, `zodRequiredPassword`, `zodOptionalUrl`, `zodOptionalHost`, etc. If a shared validator already covers your field's validation logic, **use it directly** instead of writing a custom one.
```bash
# Search for existing shared validators
grep -n "^export const zod" src/formValidation/zodCustoms.ts
Decision flow:
- Shared validator exists and matches exactly → Use it directly (e.g.,
email: zodRequiredEmail) - Shared validator exists but has different error messages → Still use the shared one. Consistent error messages across the app are better than form-specific messages. The shared validator's messages are the canonical ones.
- No shared validator exists → Create the validation inline in the form's
validationSchema.ts - You create a form-specific validator that could be reused by other forms → Move it to
src/formValidation/zodCustoms.tsand export it from there. A validator is reusable when it validates a common field type (email, URL, password, currency code, etc.) rather than a form-specific business rule.
Example — reusing a shared validator:
import { z } from 'zod'
import { zodRequiredEmail } from '~/formValidation/zodCustoms'
export const forgotPasswordValidationSchema = z.object({
email: zodRequiredEmail, // ✅ Reuses shared validator
})
Example — when to promote to shared:
If you create a validator like zodRequiredCurrencyCode in a form-specific schema and later notice it's needed in another form, move it to src/formValidation/zodCustoms.ts:
// src/formValidation/zodCustoms.ts
export const zodRequiredCurrencyCode = z
.string()
.min(1, { message: 'text_xxx' })
.length(3, { message: 'text_yyy' })
Step 2.1.1: Create the Schema File
Create a new file: src/pages/<path>/<formName>/validationSchema.ts
Use your Validation Migration Plan from Phase 1 to implement each validation.
import { z } from 'zod'
// Import any enums from generated GraphQL if needed
import { SomeEnum } from '~/generated/graphql'
// Define field schemas
const fieldSchema = z.object({
id: z.enum(SomeEnum),
// ... other fields
})
// Main form schema - implement ALL validations from the plan
export const <formName>ValidationSchema = z.object({
// Required string (was: yup.string().required())
fieldName: z.string().min(1, 'Field is required'),
// Optional string (was: yup.string())
optionalField: z.string().optional(),
// Email validation (was: yup.string().email().required())
email: z.string().email('Invalid email').min(1, 'Email is required'),
// Number with range (was: yup.number().min(0).max(100))
percentage: z.number().min(0).max(100),
// Enum (was: yup.string().oneOf([...]))
status: z.enum(SomeEnum),
// Array (was: yup.array().of(...))
items: z.array(fieldSchema),
})
// Add cross-field validations from the plan
.refine(
(data) => /* validation logic from plan */,
{ message: 'Error message', path: ['fieldName'] }
)
export type <FormName>Values = z.infer<typeof <formName>ValidationSchema>
Yup to Zod Quick Reference:
| Yup | Zod |
|---|---|
yup.string().required() |
z.string().min(1, 'Required') |
yup.string().email() |
z.string().email() |
yup.string().min(5) |
z.string().min(5) |
yup.string().max(100) |
z.string().max(100) |
yup.string().matches(/regex/) |
z.string().regex(/regex/) |
yup.string().oneOf(['a', 'b']) |
z.enum(['a', 'b']) |
yup.number().required() |
z.number() |
yup.number().min(0) |
z.number().min(0) |
yup.number().max(100) |
z.number().max(100) |
yup.number().positive() |
z.number().positive() |
yup.number().integer() |
z.number().int() |
yup.boolean() |
z.boolean() |
yup.array().of(schema) |
z.array(schema) |
yup.array().min(1) |
z.array(schema).min(1) |
yup.object().shape({}) |
z.object({}) |
.nullable() |
.nullable() |
.optional() |
.optional() |
.default(value) |
.default(value) |
.when('field', ...) |
.refine((data) => ...) |
.test('name', msg, fn) |
.refine(fn, { message: msg }) |
Step 2.2: Update Imports
Replace Formik imports:
- import { useFormik } from 'formik'
- import * as Yup from 'yup' // Remove if present
+ import { revalidateLogic, useStore } from '@tanstack/react-form'
+ import { useAppForm } from '~/hooks/forms/useAppform'
Add validation schema import:
import { <formName>ValidationSchema } from './<formName>/validationSchema'
Remove unused Formik-related imports like TextInputField with formikProps.
Step 2.3: Replace useFormik with useAppForm
Before (Formik):
const formikProps = useFormik<FormValues>({
initialValues: { name: '', ... },
validateOnMount: true,
enableReinitialize: true,
validationSchema: someSchema,
onSubmit: async (values) => { ... }
})
After (TanStack Form):
const form = useAppForm({
defaultValues: {
name: existingData?.name || '',
// ... other fields
},
validationLogic: revalidateLogic(),
validators: {
onDynamic: <formName>ValidationSchema,
},
onSubmit: async ({ value }) => {
const { field1, field2, ...rest } = value
// ... submit logic
},
})
Step 2.4: Subscribe to Form State (if needed)
For accessing form values outside of field components:
const someField = useStore(form.store, (state) => state.values.someField)
CRITICAL — Reactive form state requires useStore:
Reading form.state.isDirty, form.state.isValid, or any other form state property directly is a passive read — it does NOT create a React subscription, so the component will never re-render when that value changes.
Always use useStore for form state you need to react to in the render:
// ❌ WRONG: passive read, component won't re-render when dirty changes
const isDirty = form.state.isDirty
// ✅ CORRECT: creates a React subscription, re-renders on change
const isDirty = useStore(form.store, (state) => state.isDirty)
const isValid = useStore(form.store, (state) => state.canSubmit)
Note: Reading form.state.* inside event handlers (onClick, onSubmit, etc.) is fine since you only need the current snapshot there, not reactivity.
Step 2.5: Use Field Listeners for Side-Effects
When you need to react to a field value change (e.g., propagate a selection, derive another field's value), use listeners on form.AppField instead of useStore + useEffect:
<form.AppField
name="selectedItem"
listeners={{
onChange: ({ value }) => {
// React to the change: update derived state, call a callback, etc.
const item = items.find((i) => i.id === value)
onSelect(item)
},
}}
>
{(field) => (
<field.ComboBoxField data={comboboxData} label="Select item" />
)}
</form.AppField>
When to use listeners vs useStore:
| Use case | Tool |
|---|---|
| Read a value for conditional rendering in JSX | useStore |
| Execute a side-effect when a value changes | listeners.onChange |
| Derive another field's value from a change | listeners.onChange |
Reference: See
AddBillableMetricToCouponDialog.tsxandNameAndCodeGroup.tsxfor real-world examples of listeners.
Step 2.6: Use NameAndCodeGroup for Name + Code Fields
If the form has name and code fields, use the NameAndCodeGroup reusable component instead of separate TextInputField components:
import NameAndCodeGroup from '~/components/form/NameAndCodeGroup/NameAndCodeGroup'
// In your form JSX:
<NameAndCodeGroup form={form} fields={{ name: 'name', code: 'code' }} disableCodeInput={isEdition} />
This component:
- Renders name and code fields in a 2-column grid
- Auto-generates the
codefromnameusingformatCodeFromName(until the user manually edits the code field) - Uses the
withFieldGroupHOC (different fromwithForm— see Advanced Patterns)
Reference: See
src/components/form/NameAndCodeGroup/NameAndCodeGroup.tsxand its usage inCreateCoupon.tsx.
Duplicate-code errors: for a unique code field, surface the backend "already exists" rejection inline by calling applyExistingCodeError(formApi) (~/core/form/existingCodeError.ts) in the mutation catch on LagoApiError.ValueAlreadyExist. It sets the code field's onDynamic error to EXISTING_CODE_ERROR_MESSAGE; NameAndCodeGroup auto-clears it when the user edits the code so submit re-enables.
Reference:
useProductDrawer.tsx(product) and the charge drawers viachargeCode.ts.
Step 2.7: Update Field Components
Text Input Field:
- <TextInputField
- name="fieldName"
- label={translate('...')}
- formikProps={formikProps}
- />
+ <form.AppField name="fieldName">
+ {(field) => (
+ <field.TextInputField
+ label={translate('...')}
+ />
+ )}
+ </form.AppField>
Other field types follow the same pattern:
field.ComboBoxFieldfield.TextInputFieldfield.CheckboxField- etc.
Step 2.8: Update Form Submission
Wrap content in a form element:
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
form.handleSubmit()
}
return (
<form
{/* form content */}
</form>
)
WARNING:
<form>wrapper and CSS/layout impactFormik forms did not require a
<form>HTML element. TanStack Form does. This introduces a new DOM node that can break existing CSS layouts. Common issues include:
- Sticky footer height changes
- Flex/grid alignment breaks
- Spacing or overflow issues
min-heightbehavior changesYou will often need to add
className="flex min-h-full flex-col"to the<form>element to preserve the existing layout.The UI before and after the migration MUST be visually identical, unless the change is an intentional UI/UX improvement. Always compare the rendered page before and after the migration to catch layout regressions.
⚠️ CRITICAL — await every async call inside onSubmit:
isSubmitting (which drives form.SubmitButton's spinner) flips back to false as soon as
the onSubmit callback's own promise resolves — NOT when a fire-and-forget call inside it
finishes. If the save/mutation call isn't awaited, onSubmit returns on the next microtask and
the spinner vanishes instantly instead of covering the actual network request.
// ❌ WRONG — onSave's promise is dropped, isSubmitting flips false almost immediately
onSubmit: async ({ value }) => {
onSave(value)
},
// ✅ CORRECT — isSubmitting stays true until the mutation settles
onSubmit: async ({ value }) => {
await onSave(value)
},
This applies to every async call inside onSubmit: mutation calls, onSave/onCreate/onUpdate
callback props, etc. Grep the finished onSubmit body for any bare (non-awaited) call to a
function whose return type is Promise<...>.
Replace submit button:
Always prefer the registered form.SubmitButton over a manually-wired <Button type="submit"> with useStore subscriptions — it internally subscribes to canSubmit + isSubmitting and adds a loading state for free.
- <Button
-
- disabled={!formikProps.isValid || (isEdition && !formikProps.dirty)}
- >
+ <form.AppForm>
+ <form.SubmitButton disabled={externalLoadingState}>
{submitButtonText}
+ </form.SubmitButton>
+ </form.AppForm>
Notes:
form.SubmitButtonmust be wrapped in<form.AppForm>— it reads the form viauseFormContext().- In TanStack,
canSubmit= no validation errors + not submitting + not validating. It does NOT includeisDirty, and that is intentional — do NOT add a dirty gate. Even if the original Formik code disabled on pristine forms (!dirty), do not preserve that. The codebase convention is: submit is enabled by default and only disabled when the form has validation errors (viacanSubmit). Use a bare<form.SubmitButton>. See Submit Button Disabled Logic above.
Common mistake (do not do this): hand-wiring a <Button type="submit"> when the registered component already exists.
- const canSubmit = useStore(form.store, (s) => s.canSubmit)
- <Button variant="primary" type="submit" disabled={!canSubmit}>
- {submitLabel}
- </Button>
+ <form.AppForm>
+ <form.SubmitButton variant="primary">{submitLabel}</form.SubmitButton>
+ </form.AppForm>
Step 2.9: Update Field Value Changes
Before:
formikProps.setFieldValue('fieldName', newValue)
After:
form.setFieldValue('fieldName', newValue)
Step 2.10: Update Value Access
Before:
formikProps.values.fieldName
After (in field render):
field.state.value
After (outside field, using useStore):
const fieldValue = useStore(form.store, (state) => state.values.fieldName)
Step 2.11: Use FormLoadingSkeleton for Loading State
When the form fetches existing data (edit mode), use FormLoadingSkeleton to display a loading state:
import { FormLoadingSkeleton } from '~/styles/mainObjectsForm'
// In your form component:
if (loading) {
return <FormLoadingSkeleton id="my-form-skeleton" length={3} />
}
Reference: See
src/styles/mainObjectsForm.tsxfor the component definition andApiKeysForm.tsxfor usage.
Phase 3: Verification
Step 3.1: Validate Migration Against Plan
Go back to your Validation Migration Plan and verify:
- All field validations are implemented in Zod schema
- All cross-field validations use
.refine() - All conditional validations are handled
- All custom error messages are preserved
- Validation timing matches original (onChange, onBlur, onMount)
Step 3.2: Visual Regression Check
CRITICAL: The UI before and after the migration MUST be visually identical (unless changes are intentional UI/UX improvements).
Verify:
- Layout: The
<form>wrapper hasn't broken flex/grid layouts, sticky footers, or spacing - Field alignment: All form fields maintain their original positioning and sizing
- Error messages: Error states display in the same position and style as before
- Loading state: Loading skeleton renders correctly (if using
FormLoadingSkeleton) - Responsive behavior: The form looks correct on different viewport sizes
Step 3.3: Empirical Validation Parity Audit (RECOMMENDED for complex schemas)
Do not trust a by-eye Yup→Zod translation — verify it EMPIRICALLY with a throwaway differential test before deleting the old schema:
import { validateYupSchema } from 'formik' // the EXACT runtime path Formik used
import { ValidationError } from 'yup'
const oldErrorPaths = (values: unknown): string[] => {
try {
// sync=true; context defaults to the PREPARED values, exactly like Formik
validateYupSchema(values, oldYupSchema(), true)
return []
} catch (error) {
if (error instanceof ValidationError) return error.inner.map((e) => e.path || '')
throw error
}
}
// Compare against newZodSchema.safeParse(values) issue paths (normalize [0] vs .0)
Build a scenario matrix that includes a ''-variant of every string field (plus the
bound/cross-field combos), assert old and new produce the same invalid-field sets, then
delete the harness. Never call schema.validateSync directly — it skips
prepareDataForValidation and will falsely report parity.
Step 3.4: Test Validation Behavior
Manually test each validation case:
- Required fields: Leave empty, verify error appears
- Format validations: Enter invalid email/URL/etc, verify error
- Range validations: Enter out-of-range values, verify error
- Cross-field validations: Test dependent field combinations
- Conditional validations: Toggle conditions, verify validation changes
- Happy-path submit through EVERY field (CRITICAL — the MultipleComboBox shape regression): interact with each field — including fields hidden behind radios/conditionals (reveal → fill → submit) — and verify the submit button enables AND the mutation fires with the expected payload. A field whose stored shape mismatches the schema fails SILENTLY: button stays disabled, no error, no request. The migrated jest suite must include at least one select-then-submit test per combobox/multi-select field asserting the mutation variables.
Advanced Patterns (Complex Forms)
For complex forms with multiple sections or sub-components, use these additional patterns.
Reference: CreateCustomer Form
Study these files for complex form patterns:
src/pages/createCustomers/CreateCustomer.tsxsrc/pages/createCustomers/formInitialization/validationSchema.tssrc/pages/createCustomers/customerInformation/CustomerInformation.tsx
Pattern 1: withForm HOC for Sub-Components
When splitting a form into multiple sub-components, use the withForm HOC:
import { withForm } from '~/hooks/forms/useAppform'
import { emptyCreateCustomerDefaultValues } from './formInitialization/validationSchema'
// Define props interface
interface CustomerInformationProps {
isEdition: boolean
customer?: CustomerDetails
}
// Default props for the HOC
const defaultProps: CustomerInformationProps = {
isEdition: false,
}
// Create the component using withForm
const CustomerInformation = withForm({
defaultValues: emptyCreateCustomerDefaultValues,
props: defaultProps,
render: function Render({ form, isEdition, customer }) {
return (
<div>
<form.AppField name="name">
{(field) => (
<field.TextInputField label="Name" />
)}
</form.AppField>
{/* More fields... */}
</div>
)
},
})
export default CustomerInformation
Usage in parent form:
<CustomerInformation form={form} isEdition={isEdition} customer={customer} />
Pattern 2: Complex Zod Schemas with Refinements
For complex validation with cross-field dependencies:
import { z } from 'zod'
// Nested object schema
const addressSchema = z.object({
addressLine1: z.string().optional(),
city: z.string().optional(),
zipcode: z.string().optional(),
country: z.string().optional(),
})
// Main schema with refinements
export const customerValidationSchema = z
.object({
name: z.string().min(1, 'Name is required'),
externalId: z.string().min(1, 'External ID is required'),
currency: z.string().optional(),
timezone: z.string().optional(),
billingConfiguration: z.object({
documentLocale: z.string().optional(),
}),
shippingAddress: addressSchema,
// ... more fields
})
.refine(
(data) => {
// Cross-field validation
if (data.someCondition) {
return data.relatedField !== undefined
}
return true
},
{
message: 'Related field is required when condition is true',
path: ['relatedField'],
},
)
// Export empty default values for typing
export const emptyDefaultValues: z.infer<typeof customerValidationSchema> = {
name: '',
externalId: '',
currency: undefined,
// ... all fields with default values
}
Pattern 3: Mappers for API ↔ Form Data
Separate concerns with mapper functions:
// mappers.ts
import type { CustomerFragment } from '~/generated/graphql'
import type { CustomerFormValues } from './validationSchema'
export const mapFromApiToForm = (customer: CustomerFragment): CustomerFormValues => ({
name: customer.name || '',
externalId: customer.externalId || '',
currency: customer.currency || undefined,
billingConfiguration: {
documentLocale: customer.billingConfiguration?.documentLocale || undefined,
},
// ... transform nested objects
})
export const mapFromFormToApi = (values: CustomerFormValues): CreateCustomerInput => ({
name: values.name,
externalId: values.externalId,
currency: values.currency || null,
billingConfiguration: {
documentLocale: values.billingConfiguration.documentLocale || null,
},
// ... transform back to API format
})
Usage:
const form = useAppForm({
defaultValues: customer ? mapFromApiToForm(customer) : emptyDefaultValues,
// ...
onSubmit: async ({ value }) => {
const input = mapFromFormToApi(value)
await createCustomer({ variables: { input } })
},
})
Pattern 4: Error Handling with setErrorMap (CRITICAL)
This pattern maps Formik's setFieldError / setErrors to TanStack Form's formApi.setErrorMap.
Many forms set server-side errors on specific fields after a mutation fails (e.g., "email not found", "URL already exists"). This is easy to miss during migration because it's inside the onSubmit handler, not in the validation schema.
Formik → TanStack mapping:
| Formik | TanStack Form |
|---|---|
formikBag.setFieldError('email', errorMsg) |
formApi.setErrorMap({ onDynamic: { fields: { email: { message: errorMsg, path: ['email'] } } } }) |
formikBag.setErrors({ email: msg1, name: msg2 }) |
formApi.setErrorMap({ onDynamic: { fields: { email: { message: msg1, path: ['email'] }, name: { message: msg2, path: ['name'] } } } }) |
⚠️ CRITICAL: Error value format
Each field error in setErrorMap MUST be an object with { message, path }, NOT a plain string. The field components read errors via state.meta.errorMap and call .message on each error — a plain string will not display.
// ❌ WRONG — plain string, error will NOT display on the field
formApi.setErrorMap({
onDynamic: {
fields: {
email: translate('text_xxx'),
},
},
})
// ✅ CORRECT — object with message and path, error displays correctly
formApi.setErrorMap({
onDynamic: {
fields: {
email: {
message: translate('text_xxx'),
path: ['email'],
},
},
},
})
Full example:
const form = useAppForm({
// ...
onSubmit: async ({ value, formApi }) => {
const res = await createResource({
variables: { input: value },
})
const { errors } = res
if (hasDefinedGQLError('NotFound', errors)) {
formApi.setErrorMap({
onDynamic: {
fields: {
email: {
message: translate('text_error_email_not_found'),
path: ['email'],
},
},
},
})
return
}
if (hasDefinedGQLError('ValueAlreadyExist', errors)) {
formApi.setErrorMap({
onDynamic: {
fields: {
webhookUrl: {
message: translate('text_error_url_already_exists'),
path: ['webhookUrl'],
},
},
},
})
return
}
},
})
Reference: See
src/pages/developers/WebhookForm.tsxandsrc/pages/createCustomers/CreateCustomer.tsxfor real-world examples.
Pattern 5: Scroll to First Error on Invalid Submit
⚠️ Commonly skipped — evaluate it explicitly on every migration, even flat/simple forms. It was missed entirely in the wallet alert migration and came back as review feedback (lago-front#4061). Ask: can the form be taller than the viewport, or can an errored field be off-screen on submit? If yes, wire it.
Do NOT hand-roll the scrolling: use the shared scrollToFirstInputError helper (~/core/form/scrollToFirstInputError), which finds the first errored input inside the form element, scrolls it into view and focuses it.
import { scrollToFirstInputError } from '~/core/form/scrollToFirstInputError'
const MY_FORM_ID = 'my-form'
const form = useAppForm({
// ...
onSubmitInvalid({ formApi }) {
scrollToFirstInputError(MY_FORM_ID, formApi.state.errorMap.onDynamic || {})
},
})
// The id MUST be on the <form> element — the helper queries `#${formId} input`
return <form id={MY_FORM_ID}
Reference:
src/pages/createCustomers/CreateCustomer.tsx,src/pages/auth/SignUp.tsx,src/pages/wallet/WalletAlertForm.tsx.
Pattern 6: Conditional Field Rendering
Show/hide fields based on other field values:
const showBillingFields = useStore(
form.store,
(state) => state.values.customerType === 'business'
)
return (
<>
<form.AppField name="customerType">
{(field) => <field.ComboBoxField options={customerTypes} />}
</form.AppField>
{showBillingFields && (
<form.AppField name="vatNumber">
{(field) => <field.TextInputField label="VAT Number" />}
</form.AppField>
)}
</>
)
Pattern 7: Field Listeners for Side-Effects
Use listeners on form.AppField to react to field value changes. This is preferred over useStore + useEffect for side-effects:
// Example from AddBillableMetricToCouponDialog: propagate selection to parent via callback
<form.AppField
name="selectedBillableMetric"
listeners={{
onChange: ({ value }) => {
const billableMetric = data?.billableMetrics?.collection.find((b) => b.id === value)
onSelect(value ? billableMetri
…(truncated)