Type-safe React Native form for a tiered subscription product. Cross-field validation in one zod schema; UI is plain React Native, no UI kit.
npm install
npm run ios # iOS simulator
npm run android # Android emulatorPre-merge gate (run all three):
npm run typecheck # tsc --noEmit
npm run lint # ESLint flat config
npm test # vitest — schema unit testsRequires Node 20+ and Expo Go on device, or a configured simulator.
- Expo SDK 55 (RN 0.83, React 19) — no native code, runs in Expo Go
- react-hook-form 7
- zod 4 + @hookform/resolvers/zod
- ESLint 9 (flat config) + Prettier
- vitest 4 — schema unit tests
App.tsx – mounts the form, wires Alert on submit
src/components/SubscriptionForm/
SubscriptionForm.tsx – orchestration
index.ts – re-export
components/
ControlledField.tsx – generic Field + Controller wrapper
AddOnPicker.tsx – multi-select pool with limit + disabled state
SeatHint.tsx – live "allowed seats" hint
SeatCountInput.tsx – numeric input, decouples display from RHF number
SubmitButton.tsx – isolated submit, subscribes to isValid + isSubmitting
schema/
subscriptionFormSchema.ts – pool/limit/bounds tables, factory schema, helpers
subscriptionFormSchema.test.ts – unit tests
utils/
getFieldMessages.ts – flatten FieldError.types into a string list
src/components/ui/Chip.tsx – pressable chip primitive
src/components/ui/Field.tsx – label + child + errors[] wrapper
src/components/ui/Segmented.tsx – row of chips, single-select
src/types/FormValues.ts – z.infer<typeof schema>
src/types/Tier.ts – Tier union + TIERS const
src/types/BillingCycle.ts – BillingCycle union + BILLING_CYCLES const
src/types/AddOnId.ts – AddOnId union + ADD_ON_IDS const
eslint.config.js – Expo preset + project rules
.prettierrc.json – 4-space, single quotes, 120-col
tsconfig.json – path alias: @/* → src/*
vitest.config.ts – path alias for tests
-
All cross-field rules live in one
superRefine, not a.refine()chain. A chain short-circuits on the first failure and produces one issue at a time on the root path;superRefineruns every check in a single pass and lets eachaddIssuetarget a specific field path. The form needs both: multiple errors onaddOnIds(out-of-pool + over-limit) and onseatCount(bounds + no-downgrade) can fire together, and each must land on the right<Controller>. Validation order: dedup → pool → limit → format → bounds → state-aware. The order is structural, not semantic — every step usesaddIssuewithout short-circuit, so all active errors surface together. Step 5 (bounds) readspromoCodeviagetSeatBounds(tier, cycle, promo), which returns a zero bonus for invalid codes, so steps 4 and 5 are independent. Pairs withcriteriaMode: 'all'inuseFormanderrors: string[]in<Field>so every active issue is rendered, not just the first. -
Dependency graph:
tierandbillingCycleare roots.seatCountdepends on both pluspromoCode(a valid promo adds+10to max).addOnIdsdepends ontierandbillingCycle.promoCodevalidates its own format and re-feeds into seat bounds. -
getSeatBounds(tier, billingCycle, promoCode)is the single source of truth for bounds. Schema andSeatHintboth call it. Bounds cannot be inferred frombillingCyclealone — monthly ranges overlap across tiers, which the spec calls out. -
Dynamic resolver context via a schema factory.
defaultValuescome in as a prop (today hard-coded inApp.tsx, tomorrow they can come from a server-loaded subscription state).SubscriptionFormcallsbuildSchema({ initialSeatCount: defaultValues.seatCount })insideuseMemo, so the resolver closes over the initial state. The active state-aware rule is no-downgrade:seatCountcannot fall below the initial value. The rule is dormant when initial is the schema-level min and activates when the form starts from a non-trivial subscription state. -
Render scope is narrow. Each input lives inside its own
<Controller>which subscribes only to that field'sfieldState.useWatchinSeatHintandAddOnPickerbatches their dependencies into a single subscription per component (name: ['tier', 'billingCycle', ...]). The rootSubscriptionFormreads noformStateat all —isValidandisSubmittingare isolated insideSubmitButtonviauseFormState({ control }). Result: the root re-renders only on mount; field changes never cascade up. -
Cross-field revalidation requires explicit
trigger()calls.mode: 'all'makes RHF validate on every change, and the zod resolver runs against the whole object — but RHF only writes back the error for the field that triggered the change event, dropping errors the resolver returned for other paths. So whentierchanges, we calltrigger(['seatCount', 'addOnIds'])to force RHF to re-validate and store errors for those dependent fields; onpromoCodechange we calltrigger('seatCount').mode: 'all'is preferred overonTouchedso a dependent error can surface immediately on a field the user never touched (e.g. droppingtierfromenterprisetobasicwhileseatCount=50flags the seat field on the spot once the trigger fires). -
Out-of-pool add-ons are not silently dropped on tier downgrade. The chip stays active and deselectable, validation flags it. The user decides what to drop.
-
SeatCountInputkeeps a local string for display and syncsnumberto the form. AuseEffectreconciles external resets so the rendered text stays in sync if the form value changes from outside the input. -
Type-driven catalog. Domain unions live in
src/types/(Tier,BillingCycle,AddOnId).Record<Tier, …>andRecord<AddOnId, string>shapes in the schema make missing pool, limit, bounds, or label entries compile errors when a tier or add-on is added.
- Promo regex is
^[A-Z]{2}\d{4}$(e.g.,AB1234). The spec only gave an example. - Promo input is validated verbatim — leading/trailing whitespace fails the regex.
z.string().trim()would normalize paste-from-email scenarios but isn't applied here because the spec defines the format literally. promoCodeis modeled asz.string()with an empty-string default, notz.string().optional(). Reason: a controlledTextInputneeds a defined value on every render —undefinedwould never appear at runtime, so it shouldn't appear in the type. Two states for "absent" (undefinedand'') carry no business meaning here.- Seat input is numeric-only. Non-digit characters are stripped. Empty maps to
0internally and surfaces through the same bounds rule as any other out-of-range value ("Must be between 1 and 10 for basic monthly") — one message for0, negative, and over-max, with full tier+cycle context. The field stays visually empty. SubscriptionFormtakesonSubmit(values)as a prop;App.tsxwires the alert. Async submit works without changes: RHF tracksisSubmitting,SubmitButtonalready disables on it.
