.agents/skills/migrate-formik-to-tanstack/SKILL.md
Migrate a React form from Formik to TanStack Form following project conventions. Use this skill when the user wants to migrate a form component from Formik to TanStack Form.
npx skillsauth add getlago/lago-front migrate-formik-to-tanstackInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Before starting, gather context by reading these reference files:
src/hooks/forms/useAppform.ts - The custom useAppForm hooksrc/pages/auth/signUpForm/validationSchema.tssrc/pages/settings/roles/roleCreateEdit/RoleCreateEdit.tsxsrc/pages/developers/ApiKeysForm.tsx - Form with permissions tablesrc/pages/CreateCoupon.tsx - Conditional fields, plan/metric limits, listeners patternsrc/pages/createCoupon/dialogs/AddBillableMetricToCouponDialog.tsx - Dialog containing its own TanStack formsrc/pages/createCustomers/CreateCustomer.tsx - Main form with sub-componentssrc/pages/createCustomers/formInitialization/validationSchema.ts - Nested Zod schemas with refinementssrc/pages/createCustomers/customerInformation/CustomerInformation.tsx - HOC patternsrc/components/form/NameAndCodeGroup/NameAndCodeGroup.tsx - Reusable name+code field group using withFieldGroupuseFormik or <Formik>)TextInputField, Checkbox, etc.)formikProps usageformikPropssetFieldError, setErrors, setStatus in the onSubmit handler. These set errors on fields after a mutation fails (e.g., API returns NotFound, ValueAlreadyExist, UrlIsInvalid). Each one MUST be migrated to formApi.setErrorMap in the TanStack formThis step is critical. Document ALL validations before proceeding.
Locate validation sources - Search for:
// Yup schema (most common)
validationSchema: yupSchema
// Inline validate function
validate: (values) => { ... }
// Field-level validation
<Field validate={(value) => ...} />
// validateOnBlur, validateOnChange settings
Create 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:
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.
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 |
| ----- | -------------- | -------------- | -------------- |
| ... | ... | ... | ... |
### 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 isValid + dirty automatically
### Validation Timing
- validateOnMount: [true/false]
- validateOnChange: [true/false]
- validateOnBlur: [true/false]
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.
# Search for existing shared validators
grep -n "^export const zod" src/formValidation/zodCustoms.ts
Decision flow:
email: zodRequiredEmail)validationSchema.tssrc/formValidation/zodCustoms.ts and 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' })
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 }) |
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.
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
},
})
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.
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.
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 group={form} isDisabled={isEdition} />
This component:
code from name using formatCodeFromName (until the user manually edits the code field)withFieldGroup HOC (different from withForm — see Advanced Patterns)Reference: See
src/components/form/NameAndCodeGroup/NameAndCodeGroup.tsxand its usage inCreateCoupon.tsx.
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.CheckboxFieldWrap content in a form element:
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
form.handleSubmit()
}
return (
<form onSubmit={handleSubmit}>
{/* 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.
Replace submit button:
- <Button
- onClick={formikProps.submitForm}
- disabled={!formikProps.isValid || (isEdition && !formikProps.dirty)}
- >
+ <form.AppForm>
+ <form.SubmitButton disabled={externalLoadingState}>
{submitButtonText}
+ </form.SubmitButton>
+ </form.AppForm>
Note: form.SubmitButton handles canSubmit (validity + dirty state) automatically.
Before:
formikProps.setFieldValue('fieldName', newValue)
After:
form.setFieldValue('fieldName', newValue)
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)
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.
Go back to your Validation Migration Plan and verify:
.refine()CRITICAL: The UI before and after the migration MUST be visually identical (unless changes are intentional UI/UX improvements).
Verify:
<form> wrapper hasn't broken flex/grid layouts, sticky footers, or spacingFormLoadingSkeleton)Manually test each validation case:
For complex forms with multiple sections or sub-components, use these additional patterns.
Study these files for complex form patterns:
src/pages/createCustomers/CreateCustomer.tsxsrc/pages/createCustomers/formInitialization/validationSchema.tssrc/pages/createCustomers/customerInformation/CustomerInformation.tsxWhen 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} />
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
}
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 } })
},
})
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.
Use onSubmitInvalid to improve UX:
const form = useAppForm({
// ...
onSubmitInvalid: ({ formApi }) => {
// Get the first field with an error
const firstErrorField = Object.keys(formApi.state.fieldMeta).find(
(key) => formApi.state.fieldMeta[key]?.errors?.length > 0,
)
if (firstErrorField) {
// Scroll to the error field
const element = document.querySelector(`[name="${firstErrorField}"]`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
},
})
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>
)}
</>
)
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 ? billableMetric : undefined)
},
}}
>
{(field) => (
<field.ComboBoxField
data={comboboxData}
label={translate('text_select_billable_metric')}
loading={loading}
PopperProps={{ displayInDialog: true }}
searchQuery={getBillableMetrics}
/>
)}
</form.AppField>
// Example from NameAndCodeGroup: auto-generate code from name
<group.AppField name="name" listeners={{ onChange: handleNameChange }}>
{(field) => (
<field.TextInputField label={translate('text_name')} />
)}
</group.AppField>
When to use listeners vs useStore:
| Use case | Tool |
|----------|------|
| Read a value for conditional rendering in JSX | useStore(form.store, ...) |
| Execute a side-effect when a value changes | listeners={{ onChange }} |
| Derive another field's value from a change | listeners={{ onChange }} |
| Update external state (refs, callbacks) on change | listeners={{ onChange }} |
withFieldGroup for Reusable Field GroupswithFieldGroup is different from withForm. Use it for reusable groups of fields that can be shared across multiple forms (e.g., name + code, address fields):
import { formatCodeFromName } from '~/core/utils/formatCodeFromName'
import { withFieldGroup } from '~/hooks/forms/useAppform'
export type NameAndCodeGroupValues = {
code: string
name: string
}
export type NameAndCodeGroupProps = {
isDisabled?: boolean
}
const defaultValues: NameAndCodeGroupValues = {
code: '',
name: '',
}
const defaultProps: NameAndCodeGroupProps = {
isDisabled: false,
}
const NameAndCodeGroup = withFieldGroup({
defaultValues,
props: defaultProps,
render: function Render({ group, isDisabled }) {
const { translate } = useInternationalization()
const handleNameChange = ({ value }: { value: string }) => {
const isCodeBlurred = group.getFieldMeta('code')?.isBlurred
// Don't auto-generate code if user has manually edited it or form is disabled
if (isCodeBlurred || isDisabled) return
group.setFieldValue('code', formatCodeFromName(value))
}
return (
<div className="grid grid-cols-2 gap-6">
<group.AppField name="name" listeners={{ onChange: handleNameChange }}>
{(field) => (
<field.TextInputField
label={translate('text_name')}
placeholder={translate('text_name_placeholder')}
/>
)}
</group.AppField>
<group.AppField name="code">
{(field) => (
<field.TextInputField
label={translate('text_code')}
beforeChangeFormatter="code"
placeholder={translate('text_code_placeholder')}
disabled={isDisabled}
/>
)}
</group.AppField>
</div>
)
},
})
Key differences: withForm vs withFieldGroup:
| Aspect | withForm | withFieldGroup |
|--------|-----------|-----------------|
| Purpose | Sub-component of a specific form | Reusable field group across multiple forms |
| Receives | form prop | group prop |
| Usage | <MySection form={form} /> | <NameAndCodeGroup group={form} /> |
| Scope | Specific to one form's structure | Generic, works with any form that has matching fields |
When a dialog contains a form (e.g., selecting an item from a list), use an independent TanStack form inside the dialog content. The dialog communicates with the parent via callbacks, not by sharing form state:
// Dialog content component with its own form
const AddItemContent = ({ attachedIds, onSelect }: AddItemContentProps) => {
const [getItems, { loading, data }] = useGetItemsLazyQuery({ variables: { limit: 50 } })
const form = useAppForm({
defaultValues: { selectedItem: '' },
})
useEffect(() => { getItems() }, [getItems])
return (
<div className="p-8">
<form.AppField
name="selectedItem"
listeners={{
onChange: ({ value }) => {
const item = data?.items?.collection.find((i) => i.id === value)
onSelect(value ? item : undefined)
},
}}
>
{(field) => (
<field.ComboBoxField
data={comboboxData}
label="Select item"
loading={loading}
PopperProps={{ displayInDialog: true }}
searchQuery={getItems}
/>
)}
</form.AppField>
</div>
)
}
// Hook that opens the dialog
export const useAddItemDialog = () => {
const formDialog = useFormDialog()
const selectedItemRef = useRef<ItemFragment | undefined>()
const setDisabledRef = useSetDisabledRef()
const openDialog = ({ onSubmit, attachedIds }: OpenDialogParams) => {
selectedItemRef.current = undefined
formDialog.open({
title: 'Add item',
description: 'Select an item to add',
children: (
<AddItemContent
attachedIds={attachedIds}
onSelect={(item) => {
selectedItemRef.current = item
setDisabledRef.current(!item)
}}
/>
),
mainAction: (
<DialogActionButton
label="Add"
setDisabledRef={setDisabledRef}
/>
),
form: {
id: 'add-item-form',
submit: () => {
if (!selectedItemRef.current) throw new Error('No item selected')
onSubmit(selectedItemRef.current)
},
},
})
}
return { openDialog }
}
Key points:
useAppForm, separate from the parent formonSelect prop) and useRefuseFormDialog + DialogActionButton + useSetDisabledRef handle dialog UXform.id in dialog config links the submit button to the form elementReference: See
AddBillableMetricToCouponDialog.tsxandAddPlanToCouponDialog.tsxfor real-world examples.
Prefer deriving boolean state from form values rather than storing separate flags:
// AVOID: separate boolean flag in form state
const form = useAppForm({
defaultValues: {
hasLimits: false, // redundant flag
limitPlansList: [],
},
})
// PREFER: derive the boolean from the array length
const limitPlansList = useStore(form.store, (state) => state.values.limitPlansList)
const hasLimits = limitPlansList.length > 0
This reduces form state complexity and avoids synchronization issues between the flag and the actual data.
beforeChangeFormatter Type Coercion (CRITICAL for numeric fields)The TextInputField's beforeChangeFormatter with 'int' uses parseInt() internally, which converts the value to a number. However, when the field is emptied, formatValue returns '' (empty string) — not NaN. This means the field's runtime type is number | '', not a pure number or string.
This has cascading implications for the Zod schema, defaultValues, and dirty checking.
Schema — must accept both types:
// ❌ WRONG: z.number() rejects '' when field is emptied → runtime error
// ❌ WRONG: z.string() rejects number from parseInt → runtime error
// ❌ WRONG: z.coerce.number() coerces '' to 0 → hides "required" validation
// ✅ CORRECT: accept both number and '' with union
const schema = z.object({
gracePeriod: z
.union([z.number().max(365, { message: 'text_max_error' }), z.literal('')])
.refine((val) => val !== '', { message: 'text_required_error' }),
})
defaultValues — must match the runtime type:
const form = useAppForm({
defaultValues: {
// If the field has an existing value, use it; otherwise '' for empty/placeholder
gracePeriod: (existingValue ?? '') as number | '',
},
})
Why '' instead of 0 for empty default: If the original form showed 0 as a placeholder (field visually empty), use '' as default — otherwise TanStack Form displays 0 as the field value. Use 0 as default only when 0 is the actual saved value.
Dirty check: TanStack Form uses deep equality. If defaultValues is '' (string) and the user types 5 (number from parseInt), dirty is true. If the user clears the field, it goes back to '' and dirty is false. This works correctly as long as defaultValues type matches the runtime type.
Reference: See
EditCustomerInvoiceGracePeriodDialog.tsxandSubscriptionFeeDrawer.tsxfor real-world examples.
In legacy <Dialog ref> components, closeDialog is only available inside the actions render prop — not accessible from onSubmit. If you call closeDialog() after await form.handleSubmit(), it runs unconditionally, even when validation fails (because handleSubmit doesn't throw on validation failure — it simply doesn't call onSubmit).
Fix: use a useRef to bridge closeDialog into onSubmit:
const closeDialogRef = useRef<(() => void) | null>(null)
const form = useAppForm({
// ...
onSubmit: async ({ value }) => {
await mutation({ variables: { input: { ...value } } })
// Only reached if validation passed AND mutation succeeded
closeDialogRef.current?.()
},
})
// In the actions render prop:
<Button
onClick={async () => {
closeDialogRef.current = closeDialog
await form.handleSubmit()
}}
>
Why this works: TanStack Form's handleSubmit() runs validation first. If validation fails, onSubmit is never called, so closeDialogRef.current?.() never executes and the dialog stays open with errors visible.
Note: This pattern is specific to the legacy
<Dialog ref>system. The neweruseFormDialog/ NiceModal system handles this differently.
Never add translation keys manually to the JSON files. Always use the project's npm script:
pnpm translations:add <count>
This generates unique keys with timestamps and random suffixes (e.g., text_177583191144596sed2y63wo). After generation, populate the empty values in translations/base.json with the actual text.
CRITICAL: This phase is mandatory. Never skip test migration/creation.
After completing Phases 1-3 and verifying the form migration works correctly, invoke the /make-tests skill with the local branch name:
/make-tests <local-branch-name>
Example:
/make-tests feature/migrate-customer-form-to-tanstack
The /make-tests skill will automatically:
main branchdata-test attributes to the components.when(), .test())setFieldError, setErrors, setStatus in onSubmit)src/formValidation/zodCustoms.ts for reusable shared validators before creating new onesuseFormik with useAppFormuseStore for form state subscriptions (if needed)listeners for field-change side-effects (if needed)NameAndCodeGroup for name + code fields (if applicable)<form> element with onSubmit<form> wrapper doesn't break CSS layout (add className="flex min-h-full flex-col" if needed)form.AppField patternform.SubmitButtonsetFieldValue callssetFieldError/setErrors to formApi.setErrorMap with { message, path } format (see Pattern 4)FormLoadingSkeleton for loading state (if form fetches data)withForm HOCwithFieldGroup for reusable field groups shared across forms.refine() validations for cross-field dependenciesonSubmitInvalid for error scrollingformApi.setErrorMap for server-side errorsbeforeChangeFormatter type coercion in schema — use z.union([z.number(), z.literal('')]) for numeric fields (Pattern 11)closeDialogRef pattern for legacy <Dialog ref> forms (Pattern 12)pnpm translations:add, never manually (Pattern 13)<form> wrapper hasn't broken: sticky footer, flex/grid layout, spacing, overflowFormLoadingSkeleton)pnpm prettier --write <file>pnpm eslint <file>pnpm tsc --noEmit/make-tests <local-branch-name> skill<form onSubmit={handleSubmit}> wraps contentform.SubmitButton is inside form.AppFormuseStore to subscribe to values outside field componentsisDirty / isValid not reactive: Reading form.state.isDirty directly is a passive read — it does NOT trigger re-renders. Use useStore(form.store, (state) => state.isDirty) instead. This applies to any form-level state used in JSX (e.g., showCloseWarningDialog={isDirty}).<form> wrapper: The <form> element introduces a new DOM node that wasn't there with Formik. Common fixes:
className="flex min-h-full flex-col" to the <form> element<form> may affect margin collapse<form> must be a flex container with min-h-full so the footer can stick to the bottomform={form} prop explicitly to sub-components using withFormformApi.setErrorMap with the correct field pathspath option in .refine() matches the actual field nameemptyDefaultValues from validation schema for consistent typingdefaultValues structurelisteners={{ onChange }} on form.AppField instead of useStore + useEffectuseAppForm instance, not share the parent form. Communicate via callbacks and useRefbeforeChangeFormatter: ['int'] causes Zod type error: The formatter converts input to number via parseInt, but empty field returns ''. Use z.union([z.number(), z.literal('')]) in the schema, and as number | '' on defaultValues. See Pattern 11.0 instead of placeholder: If defaultValues is 0, the field displays 0 as a value. Use '' as default when the original form showed 0 as placeholder (visually empty field). See Pattern 11.closeDialog() after await form.handleSubmit() runs unconditionally because handleSubmit doesn't throw on validation failure. Use the closeDialogRef pattern (Pattern 12) to call closeDialog only from inside onSubmit.pnpm translations:add <count> to generate keys. See Pattern 13.Invoke this skill with:
/migrate-formik-to-tanstack <path-to-formik-form>
Where <path-to-formik-form> is the path to the existing Formik form file that needs to be migrated to TanStack Form.
Example:
/migrate-formik-to-tanstack src/pages/settings/SomeForm.tsx
development
Migrate a dialog component from the legacy imperative ref-based Dialog system to the new hook-based NiceModal dialog system (FormDialog, CentralizedDialog, or FormDialogOpeningDialog). This skill focuses only on the migration — testing is handled separately.
tools
# Make Tests Skill **Target:** `<PR_NUMBER | BRANCH_NAME>` > **Important:** If no argument was provided above (empty or missing), use the AskUserQuestion tool to ask the user what they want to create tests for. They can provide: > > - A PR number (format: `#123` or `123`) > - A branch name (local or remote, e.g., `feature/my-feature` or `origin/feature/my-feature`) ## Input Detection Determine the input type in this order: ### Step 1: Check if PR Number (Remote) If the argument is numeric
development
Create Cypress e2e tests for a specific feature. Accepts a feature name, PR number, or branch name. Navigates the codebase, adds data-test attributes if missing, writes happy-path tests following project conventions, and validates them with Cypress.
research
Execute git commit with conventional commit message analysis, intelligent staging, and message generation. Use when user asks to commit changes, create a git commit, or mentions "/commit". Supports: (1) Auto-detecting type and scope from changes, (2) Generating conventional commit messages from diff, (3) Interactive commit with optional type/scope/description overrides, (4) Intelligent file staging for logical grouping