Extract Formik Section to TanStack Drawer Skill
Target section to extract: $ARGUMENTS
Important: If no path was provided above (empty or missing), use the AskUserQuestion tool to ask the user for the path to the section component they want to extract into a drawer.
This skill extracts a Formik-based form section into an independent TanStack Form inside a ref-based Drawer, bridging data back to the parent Formik form via a callback. Both the existing Formik section and the new drawer coexist during migration.
Reference Files
Before starting, read these files to understand existing patterns:
TanStack Form Infrastructure
- useAppForm hook:
src/hooks/forms/useAppForm.ts— registered field components - Field context:
src/hooks/forms/formContext.ts—useFieldContextfor TanStack fields - Zod validation example:
src/pages/settings/teamAndSecurity/members/dialogs/CreateInviteDialog.tsx
Drawer Pattern
- Drawer component:
src/components/designSystem/Drawer.tsx— ref-basedDrawerRefwithopenDrawer/closeDrawer
Existing Implementation (reference)
- SubscriptionFeeDrawer:
src/components/plans/drawers/SubscriptionFeeDrawer.tsx— first completed extraction - SubscriptionFeeSection:
src/components/plans/SubscriptionFeeSection.tsx— section that hosts its drawer internally - PlanFormContext:
src/contexts/PlanFormContext.tsx— shared read-only context for currency/interval
TanStack Field Components
- TextInputField:
src/components/form/TextInput/TextInputFieldForTanstack.tsx - AmountInputField:
src/components/form/AmountInput/AmountInputFieldForTanstack.tsx - SwitchField:
src/components/form/Switch/SwitchFieldForTanstack.tsx - RadioGroupField:
src/components/form/Radio/RadioGroupFieldForTanstack.tsx
Migration Steps
Phase 1: Analyze the Section
- Read the target section component completely
- Identify:
- All form fields and their Formik names/types
- Which fields are editable vs read-only/disabled
- Any shared context needed (currency, interval, etc.)
- The parent component(s) that render this section
Phase 2: Define the Form Values Type
Create an interface for the drawer's form values. The field names must match the Formik field names exactly so the parent can spread them back with setValues:
interface SectionNameFormValues {
fieldA: string
fieldB: boolean
fieldC: number | null
}
Critical rule: The keys in this type must be a subset of the parent Formik form's type (PlanFormInput or equivalent). This enables the spread pattern:
formikProps.setValues({ ...formikProps.values, ...drawerValues })
Phase 3: Create the Zod Schema
Define a Zod schema that validates exactly the form values shape:
const sectionNameSchema = z.object({
fieldA: z.string().min(1, 'text_translationKeyForError'),
fieldB: z.boolean(),
fieldC: z.number().positive().nullable(),
})
Important:
- Use
z.enum(MyEnum)for GraphQL/TS string enums.z.nativeEnum()is deprecated in Zod v4. - Pass translation keys (not translated strings) as Zod error messages. The TanStack field components translate them via
useInternationalization.
Phase 4: Create the Drawer Component
Create the drawer file at src/components/plans/drawers/SectionNameDrawer.tsx following this structure:
import { revalidateLogic, useStore } from '@tanstack/react-form'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { z } from 'zod'
import { Button } from '~/components/designSystem/Button'
import { Drawer, DrawerRef } from '~/components/designSystem/Drawer'
// ... other imports
export interface SectionNameFormValues { /* ... */ }
const sectionNameSchema = z.object({ /* ... */ })
const DEFAULT_VALUES: SectionNameFormValues = { /* ... */ }
const SECTION_NAME_FORM_ID = 'section-name-drawer-form'
export interface SectionNameDrawerRef {
openDrawer: (values: SectionNameFormValues) => void
closeDrawer: () => void
}
interface SectionNameDrawerProps {
onSave: (values: SectionNameFormValues) => void
}
export const SectionNameDrawer = forwardRef<SectionNameDrawerRef, SectionNameDrawerProps>(
({ onSave }, ref) => {
const drawerRef = useRef<DrawerRef>(null)
const form = useAppForm({
defaultValues: DEFAULT_VALUES,
validationLogic: revalidateLogic(),
validators: { onDynamic: sectionNameSchema },
onSubmit: async ({ value }) => {
onSave(value)
drawerRef.current?.closeDrawer()
},
})
useImperativeHandle(ref, () => ({
openDrawer: (values) => {
// keepDefaultValues: true prevents the React adapter's formApi.update()
// from overwriting values — it compares opts.defaultValues against
// this.options.defaultValues, and since we keep DEFAULT_VALUES as both,
// they match and no overwrite occurs. isDirty starts as false because
// reset() clears all field meta.
form.reset(values, { keepDefaultValues: true })
drawerRef.current?.openDrawer()
},
closeDrawer: () => drawerRef.current?.closeDrawer(),
}))
// IMPORTANT: subscribe to isDirty via useStore — reading form.state.isDirty
// directly is a passive read that does NOT trigger re-renders.
const isDirty = useStore(form.store, (state) => state.isDirty)
const handleFormSubmit = (event: React.FormEvent) => {
event.preventDefault()
form.handleSubmit()
}
return (
<Drawer
ref={drawerRef}
title="..."
showCloseWarningDialog={isDirty}
=> form.reset()}
stickyBottomBar={({ closeDrawer }) => (
<div className="flex justify-end gap-3">
<Button variant="quaternary"
{/* form.SubmitButton uses type="submit" which requires being inside <form>.
In drawers, stickyBottomBar is outside <form> in the DOM, so use
form.Subscribe + programmatic form.handleSubmit() instead. */}
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit })}>
{({ canSubmit }) => (
<Button disabled={!canSubmit} => form.handleSubmit()}>
Save
</Button>
)}
</form.Subscribe>
</div>
)}
>
<form id={SECTION_NAME_FORM_ID}
{/* Hidden submit button enables Enter-key submission.
The visible SubmitButton is in stickyBottomBar, outside the <form> in the DOM. */}
<button type="submit" hidden aria-hidden="true" />
{/* form.AppField for each field */}
</form>
</Drawer>
)
},
)
Key patterns:
<form>wrapper: Always wrap drawer content in a<form>element with an id andonSubmithandler. Add a<button type="submit" hidden aria-hidden="true" />inside the form — the visibleSubmitButtonis instickyBottomBar(outside<form>in the DOM), so the hidden button is needed for Enter-key submission to work.onSubmiton useAppForm: The save logic lives in the form'sonSubmitconfig, not in a manual handler. This ensures Zod validation runs viaform.handleSubmit()before saving.- Programmatic submit in drawers:
form.SubmitButtonusestype="submit"which only works inside a<form>element. In drawers, thestickyBottomBaris rendered outside the<form>in the DOM, so useform.Subscribe+form.handleSubmit()instead:<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit })}>{({ canSubmit }) => (<Button disabled={!canSubmit} => form.handleSubmit()}>Save</Button>)}</form.Subscribe>. This gives you the samecanSubmitgating asSubmitButtonwith programmatic submission. reset(values, { keepDefaultValues: true }): OnopenDrawer, reset the form to the provided values while keepingDEFAULT_VALUESas the internal defaultValues. This prevents the React adapter'sformApi.update()from overwriting values on re-render, without needing auseStateworkaround.- Enhanced ref:
openDrawer(values)accepts initial values from Formik, not justopenDrawer() - Dirty detection: Subscribe via
useStore(form.store, (state) => state.isDirty)— readingform.state.isDirtydirectly is a passive read that does NOT trigger re-renders. - onSave callback: Does NOT write to Formik directly — the parent handles that via the callback
- Form reset on close:
onClose={() => form.reset()}cleans up when drawer closes
Phase 5: Integrate into the Section Component
The drawer lives inside the section component, not at the page level:
- Add a
useRef<SectionNameDrawerRef>inside the section component - Add an
onDrawerSaveprop (required, not optional) to the section's props interface - Add a trigger element (e.g.,
Selectorcomponent) that callsdrawerRef.current?.openDrawer(currentValues) - Render
<SectionNameDrawer ref={drawerRef} />inside the section
interface SectionProps {
formikProps: FormikProps<PlanFormInput>
onDrawerSave: (values: SectionNameFormValues) => void
// ... other existing props
}
Single Drawer Instance for List/Loop Sections
Critical rule for sections that render items in a loop (e.g., fixed charges, usage charges, where each charge is an accordion item):
The drawer must be rendered once at the section level, NOT inside each loop item. Only the drawerRef is passed down to each item as a prop. This prevents N drawer instances in the DOM (one per item).
// ✅ Correct: ONE drawer at the section level, ref passed to items
const ChargesSection = ({ formikProps, onDrawerSave }: ChargesSectionProps) => {
const drawerRef = useRef<ChargeDrawerRef>(null)
return (
<>
{formikProps.values.charges.map((charge, index) => (
<ChargeAccordionItem
key={charge.id}
charge={charge}
drawerRef={drawerRef} // ← pass only the ref
/>
))}
<ChargeDrawer ref={drawerRef} />
</>
)
}
// Each item calls drawerRef.current?.openDrawer(itsValues)
const ChargeAccordionItem = ({ charge, drawerRef }: ChargeAccordionItemProps) => {
return (
<Selector
=> drawerRef.current?.openDrawer(charge)}
// ...
/>
)
}
// ❌ Wrong: drawer rendered inside each loop item = N drawers in DOM
{formikProps.values.charges.map((charge, index) => (
<ChargeAccordionItem key={charge.id} charge={charge}>
<ChargeDrawer /> {/* duplicated per item! */}
</ChargeAccordionItem>
))}
Phase 6: Wire Up the Parent Page
In the parent page (e.g., CreatePlan.tsx, CreateSubscription.tsx):
- Pass
onDrawerSaveusing the spread pattern:
<SectionComponent
formikProps={formikProps}
=> {
formikProps.setValues({ ...formikProps.values, ...values })
}}
/>
- Ensure
PlanFormProviderwraps the section (provides currency/interval to the drawer):
<PlanFormProvider
currency={formikProps.values.amountCurrency || CurrencyEnum.Usd}
interval={formikProps.values.interval || PlanInterval.Monthly}
>
{/* ... section components ... */}
</PlanFormProvider>
Phase 7: Check for Missing TanStack Field Components
If the section uses a Formik field component that doesn't have a TanStack equivalent yet:
- Check
src/hooks/forms/useAppForm.tsfor thefieldComponentsmap - If missing, create a
*ForTanstack.tsxfile following the pattern of existing ones (e.g.,SwitchFieldForTanstack.tsx) - Register it in
useAppForm.ts
The TanStack field pattern:
const FieldName = (props: Omit<OriginalProps, 'name' | 'value' | 'onChange'>) => {
const field = useFieldContext<FieldType>()
return (
<OriginalComponent
{...props}
name={field.name}
value={field.state.value}
=> field.handleChange(value)}
/>
)
}
Phase 8: Add Translation Keys
Never manually create translation keys. Always use the script to generate properly formatted keys:
pnpm translations:add <count>
This generates <count> keys with the format text_<timestamp><random> and appends them with empty values to translations/base.json. Then fill in the values for each generated key.
For example, if you need 2 new strings ("Pricing settings" and "Open drawer"):
- Run
pnpm translations:add 2 - Find the new empty keys at the bottom of
translations/base.json - Fill in the values:
"text_1771963033466...": "Pricing settings" - Use the generated keys in code:
translate('text_1771963033466...')
Verification Checklist
After completing the extraction:
- TypeScript: Run
pnpm tsc --noEmit— zero errors - Lint: Run
pnpm eslinton changed files — zero new errors - Existing section: The original Formik-based accordion/section still works unchanged
- Drawer trigger: Clicking the trigger opens the drawer with current Formik values pre-filled
- Field rendering: All TanStack fields render correctly with proper labels and validation
- Save flow: Saving copies values back to Formik (verify in the existing section's display)
- Dirty detection: Closing the drawer with unsaved changes shows a warning dialog
- All parent pages: Every page that renders the section passes
onDrawerSaveand is wrapped withPlanFormProvider
Common Pitfalls
- Missing
<form>wrapper: Always wrap drawer content in a<form>element. Without it,form.handleSubmit()won't trigger the validation → submit lifecycle, and Zod validation is never enforced. - Manual value reading instead of
form.handleSubmit(): Never readform.state.valuesdirectly and pass toonSave. Always useform.handleSubmit()which runs validation first, then calls theonSubmithandler only if valid. useStatefor defaultValues: Don't useuseStateto track defaultValues. Useform.reset(values, { keepDefaultValues: true })instead — this prevents the React adapter'sformApi.update()from overwriting values while keepingisDirtycorrect.z.nativeEnum()usage: Deprecated in Zod v4. Usez.enum(MyEnum)for GraphQL/TS string enums.- Optional
onDrawerSave: Keep it required. If a parent doesn't need the drawer, it still passes a handler. This avoids conditional rendering bugs. - Field name mismatch: The drawer's form values type keys MUST match Formik field names exactly for the spread pattern to work.
- Missing PlanFormProvider: The drawer uses
usePlanFormContext()for currency/interval. Every parent that renders the section must be wrapped. - Import path:
useAppFormis imported from~/hooks/forms/useAppform(lowercase 'f'). - Revalidation logic: Always pass
validationLogic: revalidateLogic()from@tanstack/react-form. - Drawer in a loop: When the section renders items in a loop (charges, thresholds, etc.), render the drawer once at the section level and pass the
drawerRefto each item. Never render a drawer inside each loop iteration. - Manual translation keys: Never hand-craft translation keys. Always run
pnpm translations:add <count>to generate them with the correct format. - TanStack field error handling: Ensure all TanStack field wrappers (
*ForTanstack.tsx) wire up error state viauseStore(field.store, (state) => state.meta.errors)andgetErrorToDisplay(). If the underlying component accepts anerrorprop, it must be connected.