Skip to content

A1ik/subscription-form

Repository files navigation

Subscription form

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.

Cross-field validation surfacing multiple errors at once

Run

npm install
npm run ios       # iOS simulator
npm run android   # Android emulator

Pre-merge gate (run all three):

npm run typecheck # tsc --noEmit
npm run lint      # ESLint flat config
npm test          # vitest — schema unit tests

Requires Node 20+ and Expo Go on device, or a configured simulator.

Stack

  • 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

Project layout

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

Design notes

  1. 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; superRefine runs every check in a single pass and lets each addIssue target a specific field path. The form needs both: multiple errors on addOnIds (out-of-pool + over-limit) and on seatCount (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 uses addIssue without short-circuit, so all active errors surface together. Step 5 (bounds) reads promoCode via getSeatBounds(tier, cycle, promo), which returns a zero bonus for invalid codes, so steps 4 and 5 are independent. Pairs with criteriaMode: 'all' in useForm and errors: string[] in <Field> so every active issue is rendered, not just the first.

  2. Dependency graph: tier and billingCycle are roots. seatCount depends on both plus promoCode (a valid promo adds +10 to max). addOnIds depends on tier and billingCycle. promoCode validates its own format and re-feeds into seat bounds.

  3. getSeatBounds(tier, billingCycle, promoCode) is the single source of truth for bounds. Schema and SeatHint both call it. Bounds cannot be inferred from billingCycle alone — monthly ranges overlap across tiers, which the spec calls out.

  4. Dynamic resolver context via a schema factory. defaultValues come in as a prop (today hard-coded in App.tsx, tomorrow they can come from a server-loaded subscription state). SubscriptionForm calls buildSchema({ initialSeatCount: defaultValues.seatCount }) inside useMemo, so the resolver closes over the initial state. The active state-aware rule is no-downgrade: seatCount cannot 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.

  5. Render scope is narrow. Each input lives inside its own <Controller> which subscribes only to that field's fieldState. useWatch in SeatHint and AddOnPicker batches their dependencies into a single subscription per component (name: ['tier', 'billingCycle', ...]). The root SubscriptionForm reads no formState at all — isValid and isSubmitting are isolated inside SubmitButton via useFormState({ control }). Result: the root re-renders only on mount; field changes never cascade up.

  6. 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 when tier changes, we call trigger(['seatCount', 'addOnIds']) to force RHF to re-validate and store errors for those dependent fields; on promoCode change we call trigger('seatCount'). mode: 'all' is preferred over onTouched so a dependent error can surface immediately on a field the user never touched (e.g. dropping tier from enterprise to basic while seatCount=50 flags the seat field on the spot once the trigger fires).

  7. 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.

  8. SeatCountInput keeps a local string for display and syncs number to the form. A useEffect reconciles external resets so the rendered text stays in sync if the form value changes from outside the input.

  9. Type-driven catalog. Domain unions live in src/types/ (Tier, BillingCycle, AddOnId). Record<Tier, …> and Record<AddOnId, string> shapes in the schema make missing pool, limit, bounds, or label entries compile errors when a tier or add-on is added.

Assumptions / trade-offs

  • 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.
  • promoCode is modeled as z.string() with an empty-string default, not z.string().optional(). Reason: a controlled TextInput needs a defined value on every render — undefined would never appear at runtime, so it shouldn't appear in the type. Two states for "absent" (undefined and '') carry no business meaning here.
  • Seat input is numeric-only. Non-digit characters are stripped. Empty maps to 0 internally 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 for 0, negative, and over-max, with full tier+cycle context. The field stays visually empty.
  • SubscriptionForm takes onSubmit(values) as a prop; App.tsx wires the alert. Async submit works without changes: RHF tracks isSubmitting, SubmitButton already disables on it.

About

Type-safe RN subscription form — cross-field validation with zod superRefine, RHF with narrow render scope + topics: react-native, expo, react-hook-form, zod, typescript, form-validation.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors