Production cases reveal the boundary
Most form failures appear where a simple demo has no opinion: a slow uniqueness check, a detail request that completes after navigation, or a new section added by another team. These cases are not exceptions to architecture. They test whether ownership and lifecycle are real.
Who this is for
Frontend engineers and technical leads responsible for production React forms that load server data, validate asynchronously, or must evolve without a rewrite.
Decision checklist
- Can an outdated asynchronous response change the current field state?
- Does add versus edit change only the initial snapshot, or has it leaked into every component?
- Are schema modules composable without exposing a library’s internal form wiring?
- Can a field be added or removed by changing one domain module and one owning composition point?
- Can the migration be released screen by screen with a rollback path?
Case 1: Validate a unique field on blur
Problem. An editor must warn that a unique identifier already exists before the final save.
Why it happens. Uniqueness depends on current server data. Calling the API on every keystroke produces noisy requests and races; waiting until Save creates a late, avoidable failure.
Better UX. Run local required/length checks while editing. On blur, show a pending indicator, then a specific duplicate error or a quiet success state. Offer retry on a transport failure, but do not label an unavailable check as “name available.”
const form = useForm({
defaultValues: { identifier: '', name: '' },
validators: {
onSubmit: recordSchema,
},
});
<form.Field
name="identifier"
validators={{
onBlurAsync: async ({ value, signal }) => {
if (!value.trim()) return undefined;
const result = await records.checkIdentifier(value, { signal });
return result.exists ? 'This identifier is already in use.' : undefined;
},
}}
>
{(field) => <IdentifierInput field={field} />}
</form.Field>
The exact API surface can vary by TanStack Form version; the architecture does not. Associate work with the value that started it, cancel or ignore superseded requests, and run the authoritative uniqueness check again during Save. The input renders pending, error, and retry affordances; the feature provides the API dependency.
type RecordSaveError = {
message?: string;
code?: string;
};
function mapRecordSaveError(error: RecordSaveError) {
const message = error.message ?? 'The record could not be saved.';
if (error.code === 'duplicate') {
return {
fieldErrors: { identifier: 'This identifier already exists.' },
};
}
return {
formError: message,
retryable: true,
};
}
Case 2: Add and edit records with different snapshots
The common mistake is if (isEdit) throughout every section. The meaningful difference is normally initial data: Add starts from defaults; Edit starts from a loaded, normalized entity. The same schema, sections, grid, Save, and Discard rules can then apply.
const initial = mode === 'add'
? createRecordDefaults()
: await recordService.get(recordId);
const form = useForm({
defaultValues: initial,
validators: { onSubmit: recordSchema },
});
While edit detail loads, render a clear loading state rather than briefly mounting empty controlled fields. If the route changes from one record ID to another, cancel the old load and reset only after the new snapshot is accepted. For an editable document-line grid, create temporary row IDs, permit local add/remove, and submit the whole array or an explicit patch contract. Do not mutate a server list in place and hope reset can reconstruct intent.
Case 3: Compose schemas, not hidden feature dependencies
Identity, contact, policy, and collection modules can each expose three public things: a schema, a default-value factory, and a small UI component. The parent owns composition and workflow rules. Related resources should remain references or IDs rather than mutable nested objects.
export const identitySchema = z.object({
identifier: z.string().min(1).max(32),
name: z.string().min(1),
});
export const policySchema = z.object({
limit: z.number().min(0),
status: z.enum(['active', 'inactive']),
});
export const recordSchema = z.object({
...identitySchema.shape,
...policySchema.shape,
contacts: z.array(z.object({ value: z.string().min(1) })),
});
The schema proves draft shape, not live configuration. Validate local shape before submit, then let the authoritative service verify uniqueness and environment-specific rules. This prevents a cached lookup from becoming false authority.
Keep field-path helpers, visual layout, and internal validation adapters private to the module. Keep schemas, defaults, value types, and explicit extension points public. This prevents a consumer from depending on internal form instances while still allowing a new business feature to compose the domain object.
Case 4: Optimize for the next field change
Maintainability comes from dependency direction:
When adding a field, update its domain schema/default, its section, and any explicit cross-field rule. When removing one, the type system and schema should identify affected composition points. If a reusable input imports a feature service, or one section reaches into another section’s private state, that change has too many hidden paths.
Case 5: Migrate without a rewrite freeze
Use a strangler migration. Keep the existing form library on stable screens, introduce the target architecture in one bounded feature, and extract shared schemas and services first. Avoid a compatibility abstraction that tries to make React Hook Form and TanStack Form look identical; it usually hides lifecycle differences and prolongs two mental models.
For Yup to Zod, migrate schemas at feature boundaries and prove equivalence with representative valid and invalid fixtures. For JavaScript to TypeScript, type the API contract and default values before attempting every component. Release behind normal product slices, instrument submit failures and latency, and retain a rollback path per screen. A safe migration reduces blast radius; it does not maximize the number of files changed.
Key takeaways
- Treat stale async work as a correctness issue, not just a loading-state issue.
- Model Add and Edit with different snapshots before creating different component trees.
- Publish schemas, defaults, and types as module contracts; keep wiring private.
- Let dependency direction make field changes local and reviewable.
- Migrate by bounded feature with behavioral proof, not a framework-wide switch.
Conclusion
Reliable form architecture holds when data arrives late, sections change, and teams migrate at different speeds. The recurring solution is explicit ownership: the form owns a draft, domain modules own their contracts, services own I/O, and each asynchronous result has a clear place in the lifecycle.