Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .claude/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Type this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
skills CLI
$ npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a codex
Project install goes to .agents/skills/; add -g for ~/.codex/skills/.
Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .agents/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a cursor
Project install goes to .agents/skills/; add -g for ~/.cursor/skills/.
Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .cursor/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
skills CLI
$ npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a gemini-cli
Project install goes to .agents/skills/; add -g for ~/.gemini/skills/.
Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .gemini/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Installs for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
skills CLI
$ npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a github-copilot
Project install goes to .agents/skills/; add -g for ~/.copilot/skills/.
Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .github/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a opencode
OpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
Install the "migrate-formik-to-tanstack" agent skill from https://github.com/getlago/lago-front/tree/main/.agents/skills/migrate-formik-to-tanstack into .opencode/skills/migrate-formik-to-tanstack/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "migrate-formik-to-tanstack", then confirm the skill loads.
OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Facts
Skill name
migrate-formik-to-tanstack
GitHub stars
163
Token cost
~16k tokens
SKILL.md length
4,770 words
Files
3
Skills in repo
17
Repo updated
First seen
Licence
AGPL-3.0
At a glance
Migrate a React form from Formik to TanStack Form following project conventions.
Works in 7 steps: Pre-Migration Analysis → Verification → Test Migration → …
The user wants to migrate a form component from Formik to TanStack Form
SKILL.md covers Prerequisites, Migration Steps, Advanced Patterns (Complex… and Checklist
Calls pnpm and tsc
What it does
Migrate Formik To Tanstack is an agent skill from getlago/lago-front. 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.
Its SKILL.md is about 16k tokens, which your agent loads only when the skill is triggered. The skill folder holds 3 other files (for example `examples/before-after.md` and `examples/complex-form-patterns.md`).
It sits in Frontend & Design. It works with TanStack and React. The repository describes itself as: Open Source Metering and Usage Based Billing. The licence is AGPL-3.0.
When your agent uses it
The user wants to migrate a form component from Formik to TanStack Form
Read from SKILL.md and the folder at commit 4e13a85. It shows what the files ask for, not the result of running them.
Tool permissions
Pre-approves these tools, so the agent can use them without asking each time:
Read
Glob
Grep
Edit
Write
Bash
AskUserQuestion
From allowed-tools in the SKILL.md frontmatter.
Runs code
Shell commands in SKILL.md call:
pnpm
tsc
From the folder's file list and the shell code blocks in SKILL.md.
Network
No URLs in SKILL.md. Its commands use pnpm, which can reach the network depending on how they are called.
From URLs in SKILL.md, links to its own repository left out.
Credentials
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Context cost
Migrate Formik To Tanstack loads about 16k tokens when it runs. Until then it costs about 50 tokens; SKILL.md has 4,770 words of instructions outside code blocks.
Always· name and description, kept in context so the agent knows when to use it
~50
When it runs· the whole SKILL.md, loaded when a task matches
~16k
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
Safety
Auto-check: notes
The automated check noted patterns worth knowing about, such as sudo or a known installer.
NotePre-approves every shell command (allowed-tools: Bash)SKILL.md
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
Download SKILL.mdSave it as .claude/skills/migrate-formik-to-tanstack/SKILL.md (or your agent's skills folder). This skill also uses 2 other files; get the full folder from GitHub.
name
migrate-formik-to-tanstack
description
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.
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-frontend-patterns (references/forms.md), 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 custom useAppForm hook
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 using withFieldGroup
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 (useFormik or <Formik>)
Submit handler logic
Field components used (TextInputField, Checkbox, etc.)
Any formikProps usage
Sub-components that receive formikProps
Server-side error handling — search for setFieldError, 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 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 ('') to undefined (arrays and nested objects included), and
passes the PREPARED values as the Yup context too. A literal Yup→Zod translation
silently diverges on every ''-sensitive check:
Number('') === 0 (passes !isNaN) while Number(undefined) is NaN (fails it)
→ "at least one of X/Y" rules stop firing on emptied fields
min <= max cross-checks run against 0 instead of being skipped
optional numeric fields (yup.number().min().max()) accepted an emptied input
('' → undefined → not required); a raw-'' port wrongly rejects it
Rule: 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:
typescript
const prepared = <T,>(value: T): T | undefined =>
value === ('' as unknown as T) ? undefined : value
⛔ CRITICAL — the schema must validate what the WRAPPER stores, not what Formik stored
Formik forms often bind raw components manually, with an onChange that TRANSFORMS the
value before it reaches form state:
tsx
// Formik: onChange is a shape ADAPTER — options in, bare id strings stored
<MultipleComboBox
name="sectionIds"
onChange={(options) => formikProps.setFieldValue('sectionIds', options.map(({ value }) => value))}
/>
The registered field.*Field wrappers call field.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, canSubmit stays false forever: submit button disabled, no visible error,
no network request (the MultipleComboBox shape regression, lago-front#3932 → #4067). Seeding is
broken the same way: defaultValues written 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 / setFieldValue transforms — 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
to field.handleChange / expects in useFieldContext<...>().
Write the Zod schema against the WRAPPER's shape; move id-extraction/mapping into
onSubmit (the API contract doesn't change).
Seed defaultValues in the wrapper's shape too (e.g. build { value, label } options
from the existing selection).
Derive FormValues with z.infer<typeof schema> so the schema and the type cannot drift.
z.array(z.looseObject({ value: z.string() })) + map to ids in onSubmit
ComboBoxField
the option's value as string | undefined (clearing sets undefined)
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) and CreateQuote for the MultipleComboBox convention.
// 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:
typescript
// 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'],
})
Note: Async validations require special handling in TanStack Form.
Step 1.3: Create Validation Migration Plan
Before writing any code, create a plan document:
markdown
## 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.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:
typescript
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:
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:
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:
tsx
<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.tsx and NameAndCodeGroup.tsx for 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:
tsx
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 code from name using formatCodeFromName (until the user manually edits the code field)
Uses the withFieldGroup HOC (different from withForm — see Advanced Patterns)
Reference: See src/components/form/NameAndCodeGroup/NameAndCodeGroup.tsx and its usage in CreateCoupon.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 via chargeCode.ts.
Formik 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-height behavior changes
You 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 call inside onSubmit that RETURNS A PROMISE:
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.
The callee's return type decides it, in both directions. Read the signature before awaiting —
a callback prop named onSave is as often => void as => Promise<void>:
typescript
// Given `onSave: (value: T) => Promise<void>`
onSubmit: async ({ value }) => { onSave(value) } // ❌ promise dropped, spinner vanishes
onSubmit: async ({ value }) => { await onSave(value) } // ✅ spinner covers the request
// Given `onSave: (value: T) => void` — there is nothing to await
onSubmit: async ({ value }) => { await onSave(value) } // ❌ redundant await on a non-promise
onSubmit: ({ value }) => { onSave(value) } // ✅
The redundant direction is not harmless and no local gate catches it: Sonar fails it as
typescript:S4123, while @typescript-eslint/await-thenable is off in this repo, so lint and
tsc --noEmit both stay green and the finding only lands on the PR.
This applies to every call inside onSubmit: mutation calls, onSave/onCreate/onUpdate
callback props, etc. Grep the finished onSubmit body twice — for a bare (non-awaited) call to
a function returning Promise<...>, and for an await on one that does not.
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.
form.SubmitButton must be wrapped in <form.AppForm> — it reads the form via useFormContext().
In TanStack, canSubmit = no validation errors + not submitting + not validating. It does NOT include isDirty, 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 (via canSubmit). 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.
Step 2.11: Use FormLoadingSkeleton for Loading State
When the form fetches existing data (edit mode), use FormLoadingSkeleton to display a loading state:
tsx
import { FormLoadingSkeleton } from '~/styles/mainObjectsForm'
// In your form component:
if (loading) {
return <FormLoadingSkeleton id="my-form-skeleton" length={3} />
}
Reference: See src/styles/mainObjectsForm.tsx for the component definition and ApiKeysForm.tsx for 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:
typescript
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.
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.
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.
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.
typescript
// ❌ 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'],
},
},
},
})
Reference: See src/pages/developers/WebhookForm.tsx and src/pages/createCustomers/CreateCustomer.tsx for 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.
typescript
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} onSubmit={...}>
Use listeners on form.AppField to react to field value changes. This is preferred over useStore + useEffect for side-effects:
tsx
// 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>
tsx
// 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 }}
Pattern 8: withFieldGroup for Reusable Field Groups
withFieldGroup is different from withForm. Use it for reusable groups of fields that can be shared across multiple forms (e.g., name + code, address fields):
Generic, works with any form that has matching fields
Pattern 9: Dialog with Independent Form
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:
The form.id in dialog config links the submit button to the form element
Reference: See AddBillableMetricToCouponDialog.tsx and AddPlanToCouponDialog.tsx for real-world examples.
Pattern 10: Derive State from Form Values
Prefer deriving boolean state from form values rather than storing separate flags:
tsx
// 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.
Show full SKILL.md (1,939 more words)Show less
Pattern 11: 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) — notNaN. 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:
typescript
// ❌ 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:
typescript
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.tsx and SubscriptionFeeDrawer.tsx for real-world examples.
Pattern 12: Dialog Close After Submit (ref-based Dialog pattern)
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:
typescript
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 newer useFormDialog / NiceModal system handles this differently.
Pattern 13: Adding New Translation Keys
Never add translation keys manually to the JSON files. Always use the project's npm script:
bash
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.
Phase 4: Test Migration
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:
Create or migrate tests following project conventions
Checklist
Phase 1: Pre-Migration Analysis
Read target form file completely
Identify all form fields and types
Validation Analysis (CRITICAL):
Account for prepareDataForValidation — Formik validated '' as undefined; map every isNaN/presence/numeric check with ''-as-absent semantics
Field Value-Shape Map (CRITICAL — the MultipleComboBox shape regression) — one row per field: inventory custom onChange/setFieldValue transforms (shape adapters the wrapper won't reproduce), read each *ForTanstack wrapper's stored type, write the Zod shape against the WRAPPER
Locate Yup schema / validate function / field-level validations
await every call inside onSubmit whose return type is Promise<...> (mutations, onSave/onCreate/onUpdate props), and only those — a dropped await makes the submit spinner vanish before the request settles, while an await on a => void callback is a Sonar typescript:S4123 that lint and tsc do not catch
Update each field to use form.AppField pattern
Replace submit button with form.SubmitButton
Update setFieldValue calls
Migrate setFieldError/setErrors to formApi.setErrorMap with { message, path } format (see Pattern 4)
Use FormLoadingSkeleton for loading state (if form fetches data)
Phase 2b: Complex Forms (if applicable)
Create mappers for API ↔ Form data transformation
Update sub-components to use withForm HOC
Use withFieldGroup for reusable field groups shared across forms
Add .refine() validations for cross-field dependencies
Implement onSubmitInvalid for error scrolling (Pattern 5 — commonly skipped: evaluate even on simple forms, don't gate it on "complex")
Add formApi.setErrorMap for server-side errors
Migrate dialogs with forms to independent TanStack forms (Pattern 9)
Derive boolean state from form values instead of separate flags (Pattern 10)
Handle beforeChangeFormatter type coercion in schema — use z.union([z.number(), z.literal('')]) for numeric fields (Pattern 11)
Use closeDialogRef pattern for legacy <Dialog ref> forms (Pattern 12)
Add new translation keys via pnpm translations:add, never manually (Pattern 13)
Phase 3: Verification
Visual Regression Check (CRITICAL):
Compare UI before and after migration — must be visually identical
Check field alignment, datepicker positioning, error message placement
Test loading state renders correctly (if using FormLoadingSkeleton)
Validation Verification:
Test all required field validations
Test all format validations (email, URL, etc.)
Test all range validations (min, max)
Test all cross-field validations
Test all conditional validations
Happy-path submit through every field (incl. conditionally-rendered ones): reveal → fill → submit → mutation fires with expected payload (the MultipleComboBox shape regression)
Test all server-side errors (trigger mutation errors, verify field error displays)
Verify error messages match original
Run pnpm prettier --write <file>
Run pnpm eslint <file>
Run pnpm tsc --noEmit
Phase 4: Test Migration
Invoke /make-tests <local-branch-name> skill
Follow the make-tests skill workflow to completion
Common Issues
Basic Issues
Form not submitting: Ensure <form onSubmit={handleSubmit}> wraps content
Submit button always disabled: Check form.SubmitButton is inside form.AppForm
Values not updating: Use useStore to subscribe to values outside field components
TypeScript errors: Ensure validation schema matches form field types
isDirty / 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}).
Layout & CSS Issues
Layout broken after adding <form> wrapper: The <form> element introduces a new DOM node that wasn't there with Formik. Common fixes:
Add className="flex min-h-full flex-col" to the <form> element
Check if sticky footer height changed — the form needs to fill the available space
Verify datepicker/popover alignment — extra DOM nesting can shift positioned elements
Check error message spacing — the <form> may affect margin collapse
Sticky footer not sticking or wrong height: The <form> must be a flex container with min-h-full so the footer can stick to the bottom
Complex Form Issues
Sub-component not receiving form: Pass form={form} prop explicitly to sub-components using withForm
Nested validation not working: Ensure nested Zod schemas are properly composed (not just referenced)
Server errors not displaying: Use formApi.setErrorMap with the correct field paths
Refine validation failing silently: Check that the path option in .refine() matches the actual field name
Default values type mismatch: Export and use emptyDefaultValues from validation schema for consistent typing
Form dirty state incorrect with mappers: Ensure mapper output structure exactly matches defaultValues structure
Side-effect on field change not working: Use listeners={{ onChange }} on form.AppField instead of useStore + useEffect
Dialog form state leaking to parent: Dialogs should use their own useAppForm instance, not share the parent form. Communicate via callbacks and useRef
Numeric Field & Dialog Issues
beforeChangeFormatter: ['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.
Field shows 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.
Dialog closes even when validation fails: 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.
Translation keys added manually cause inconsistent IDs: Always use pnpm translations:add <count> to generate keys. See Pattern 13.
Setting an array item on an undefined base corrupts the value: form.setFieldValue('rules[0]', item) when rules is undefined creates a plain OBJECT ({0: item}), not an array (Formik/lodash created an array). Any .forEach/Array.isArray consumer then breaks. Always set the whole array instead (form.setFieldValue('rules', [item])); bracket-index paths are safe only on an EXISTING array.
Submit stuck in loading forever (no errors shown, no mutation fired): an exception thrown INSIDE the validation phase (e.g. a superRefine crashing on malformed data) fires after isSubmitting=true but before the reset — the button spins forever. superRefine must NEVER throw: guard array shapes with Array.isArray, optional chains everywhere. This symptom triad (infinite loading + zero validation errors + no network call) = throwing validator.
Zod v4 replaces empty messages with "Invalid input": the Yup required('') pattern (mark invalid without visible text) cannot be ported as message: '' — Zod substitutes its default "Invalid input" which then renders. Use a real translation key (generic required key: text_1771342994699klxu2paz7g8 "Field is required") or suppress display via the wrapper's errorOverride/silentError.
Error labels needing translate variables: the *ForTanstack wrappers translate message KEYS without variables — a {{min}}/{{max}} label renders raw placeholders. Emit the key from the schema, and let the COMPONENT translate with variables via the wrapper's errorOverride prop (errorOverride={hasError ? translate(key, { min, max }) : undefined}). TextInputField, AmountInputField and DatePickerField all support errorOverride (string replaces the error, false suppresses it).
Validation timing changes reviewer perception: Formik's validateOnMount disabled the submit instantly; revalidateLogic() surfaces errors at the FIRST submit attempt, then live. Same rules, different moment — warn reviewers/QA or they will report "missing validations".
The <form> wrapper enables Enter-key submission: a native form submits on Enter inside inputs — behavior the Formik version did not have. Usually desirable; flag it in the visual/UX check.
Submit spinner disappears instantly / doesn't cover the network request: an un-awaited
async call inside onSubmit (onSave(value) instead of await onSave(value)) lets the
onSubmit promise resolve on the next microtask, so isSubmitting — and form.SubmitButton's
loading prop — flips back to false before the mutation actually settles. await the
save/mutation call when it returns a promise — check the signature, since the mirror mistake
(await on a => void callback) is a Sonar typescript:S4123 that lint and tsc let through.
Submit button permanently disabled after selecting in a MultipleComboBox (no error shown, no request sent): the schema declares z.array(z.string()) (the OLD Formik shape, produced by a manual onChange adapter that the migration dropped) but MultipleComboBoxField stores WHOLE option objects ({ value, label, … }[]). Zod rejects every selection → canSubmit never turns true; seeding bare ids also renders no tags. Fix: z.array(z.looseObject({ value: z.string() })), map to ids in onSubmit, seed defaultValues as { value, label } options, derive FormValues via z.infer. This regression shipped once (lago-front#3932, fixed in #4067); see the Field Value-Shape Map in Phase 1.
Section validity for UNMOUNTED fields: Formik's errors.someArray reflected schema errors regardless of what was rendered. The TanStack equivalent for an accordion validity icon is the form-level error map, not fieldMeta (which only covers mounted fields). Validator-produced errors live DIRECTLY on errorMap.onDynamic, keyed by field path (e.g. someArray[0].prop) — the .fields sub-shape does NOT exist there; it only appears for errors set manually via form.setErrorMap({ onDynamic: { fields: ... } }) (server errors). Read it as: useStore(form.store, (s) => { const dynamicErrors = (s.errorMap as { onDynamic?: Record<string, unknown> })?.onDynamic ?? {}; return Object.entries(dynamicErrors).some(([k, v]) => k.startsWith('someArray') && !!v) }).
Usage
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.
Migrate Formik To Tanstack next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
Migrate Formik To Tanstack compared with similar skills
Skill
Stars
Used in
Tokens
Auto-check
Licence
Repo updated
Migrate Formik To Tanstack this skillgetlago/lago-front
Guidelines for React 18 and TypeScript apps covering Suspense data fetching, lazy loading, feature folders, MUI v7 styling, TanStack Router and performance.
A skill your agent uses when working with BuzzForm schema-driven forms, @buildnbuzz/form-core, @buildnbuzz/form-react, or any related APIs like defineSchema, InferType, FormProvider, useDataField…
A skill your agent uses when asked to babysit, monitor, shepherd, or keep working on a GitHub pull request until it is green, review-ready, approved, mergeable, or ready to merge.
You are an advanced Docker containerization expert with comprehensive, practical knowledge of container optimization, security hardening, multi-stage builds, orchestration patterns, and production…
Migrate a React form from Formik to TanStack Form following project conventions. Migrate Formik To Tanstack is an agent skill from getlago/lago-front. Migrate a React form from Formik to TanStack Form following project conventions.
When should I use Migrate Formik To Tanstack?
Migrate Formik To Tanstack fits situations like: the user wants to migrate a form component from Formik to TanStack Form.
How do I install Migrate Formik To Tanstack in Claude Code?
Run `npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a claude-code`. Or copy the skill folder (.agents/skills/migrate-formik-to-tanstack in getlago/lago-front) into .claude/skills/migrate-formik-to-tanstack in your project. Claude Code loads it when a task matches its description.
How do I install Migrate Formik To Tanstack in Codex?
Run `npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a codex`. Or copy the skill folder (.agents/skills/migrate-formik-to-tanstack in getlago/lago-front) into .agents/skills/migrate-formik-to-tanstack in your project. Codex loads it when a task matches its description.
Can I use Migrate Formik To Tanstack in Cursor, Gemini CLI or GitHub Copilot?
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add getlago/lago-front --skill migrate-formik-to-tanstack -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/migrate-formik-to-tanstack, .gemini/skills/migrate-formik-to-tanstack, .github/skills/migrate-formik-to-tanstack and .opencode/skills/migrate-formik-to-tanstack in your project.
What does Migrate Formik To Tanstack need to run?
Going by SKILL.md and its folder, Migrate Formik To Tanstack needs the command-line tools its instructions call (pnpm and tsc). Its frontmatter pre-approves these tools: Read, Glob, Grep, Edit, Write, Bash, AskUserQuestion.
Does Migrate Formik To Tanstack access the network?
SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.
Is Migrate Formik To Tanstack safe to install?
Our automated static check of SKILL.md found notes only (pre-approves every shell command (allowed-tools: bash)), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.
What licence does Migrate Formik To Tanstack use?
Migrate Formik To Tanstack is published under the AGPL-3.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
How many tokens does Migrate Formik To Tanstack use?
About 16k tokens (SKILL.md is roughly 65k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
What are the alternatives to Migrate Formik To Tanstack?
Skills that share tags, products or a category with Migrate Formik To Tanstack: React Frontend Development Guidelines (diet103/claude-code-infrastructure-showcase, 10k stars), Frontend Code Review (langflow-ai/langflow, 155k stars), Morphous Catalog (Ameyanagi/morphos, 102 stars) and Buzzform (buildnbuzz/buzzform, 102 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
Who maintains Migrate Formik To Tanstack?
getlago (a GitHub organization) maintains it in getlago/lago-front, which has 163 GitHub stars. The repository holds 17 skills in this directory. The repository was last updated on October 9, 2026.
Source: getlago/lago-front on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.