the validation resolver. The developer must ensure the generic matches the schema output.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod-resolver';
import * as z from 'zod';
// Schema definition
const onboardingSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
address: z.object({
city: z.string(),
zip: z.string(),
}),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
// Manual type derivation required
type OnboardingData = z.infer<typeof onboardingSchema>;
export function useOnboardingForm() {
// Generic must match schema output; resolver is separate
const formApi = useForm<OnboardingData>({
resolver: zodResolver(onboardingSchema),
mode: 'onBlur', // Global mode limits granular control
});
// Async validation requires manual state management
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const checkEmail = async (email: string) => {
setIsCheckingEmail(true);
// Simulate API call
await new Promise((r) => setTimeout(r, 500));
setIsCheckingEmail(false);
};
return { formApi, isCheckingEmail, checkEmail };
}
Architecture Rationale:
This approach separates concerns but introduces coupling points. The z.infer type must be manually kept in sync with the schema. The global mode: 'onBlur' applies to all fields, making it difficult to implement instant feedback for password strength without custom handlers. Async validation state (isCheckingEmail) must be managed manually, increasing component complexity.
2. TanStack Form Implementation
TanStack Form infers types from default values and uses a reactive store. Validation is handled via validator objects with granular triggers.
import { useForm } from '@tanstack/react-form';
// Default values drive type inference
const initialPayload = {
username: '',
email: '',
address: { city: '', zip: '' },
password: '',
confirmPassword: '',
};
export function useOnboardingForm() {
// Types inferred from initialPayload
const formEngine = useForm({
defaultValues: initialPayload,
validators: {
onChange: ({ value }) => {
// Validation logic inline or via schema
if (value.password !== value.confirmPassword) {
return 'Passwords must match';
}
},
},
});
// Granular async handling via field state
const emailField = formEngine.getField('email');
// Async validation can be triggered per field
const validateEmail = async () => {
emailField.meta.isValidating = true;
await new Promise((r) => setTimeout(r, 500));
emailField.meta.isValidating = false;
};
return { formEngine, validateEmail };
}
Architecture Rationale:
TanStack Form eliminates the generic boilerplate and provides granular validation control. The reactive store ensures automatic subscription management, reducing render optimization effort. However, types are derived from defaultValues. If the schema introduces optional fields or unions that aren't reflected in the defaults, type inference may be incomplete. Additionally, maintaining both defaults and a validation schema creates a dual-source-of-truth scenario.
3. Formisch Implementation
Formisch couples directly to Valibot, making the schema the single source of truth for types, validation, and structure.
import { useForm } from 'formisch';
import * as v from 'valibot';
// Schema defines types, validation, and async rules
const onboardingBlueprint = v.object({
username: v.string(v.minLength(3)),
email: v.pipe(
v.string(v.email()),
v.checkAsync(async (email) => {
// Async validation lives in schema
await new Promise((r) => setTimeout(r, 500));
return true;
}, 'Email unavailable')
),
address: v.object({
city: v.string(),
zip: v.string(),
}),
password: v.string(v.minLength(8)),
confirmPassword: v.string(),
});
// No generic needed; types inferred from schema
export function useOnboardingForm() {
const formContext = useForm({
schema: onboardingBlueprint,
});
// Async state and validation timing handled by schema
// Submit-time validation by default, switches to live feedback after first attempt
return { formContext };
}
Architecture Rationale:
Formisch eliminates type drift by deriving all types directly from the Valibot schema. Validation logic, including async checks and cross-field rules, resides within the schema, keeping components clean. The library handles validation timing automatically (submit-time initially, then live feedback) and manages async state internally. This reduces the number of moving pieces and ensures that schema changes propagate instantly to the form's type system.
Pitfall Guide
1. The Generic Illusion in React Hook Form
Explanation: Developers assume the generic type passed to useForm enforces runtime safety. In reality, the generic is compile-time only and has no connection to the validation resolver. A schema can reject fields that the type accepts, or vice versa.
Fix: Derive the TypeScript type directly from the schema using z.infer or equivalent. Alternatively, adopt a schema-first library where the schema drives types automatically.
2. Default Value Type Narrowing in TanStack Form
Explanation: TanStack Form infers types from defaultValues. If a field starts as undefined or an empty string, the inferred type may be narrower than the schema allows, causing type errors when the schema expects a union or optional type.
Fix: Ensure default values accurately represent the full type range. Use explicit type annotations or schema-based type inference if available. Validate that defaults align with schema constraints.
3. Watch Subscription Explosion
Explanation: In React Hook Form, using watch() or useFormState in parent components subscribes to the entire form state. This causes unnecessary re-renders when any field changes, degrading performance in large forms.
Fix: Use useWatch in child components to subscribe only to specific fields. Avoid broad subscriptions in parent components. Consider migrating to a library with automatic granular subscriptions like TanStack Form or Formisch.
4. Global Validation Mode Limitations
Explanation: React Hook Form's mode option applies globally to all fields. This makes it difficult to implement mixed validation timing, such as instant feedback for passwords and blur-only for emails.
Fix: Implement custom event handlers for specific fields. Alternatively, use a library that supports per-validator triggers, like TanStack Form, or schema-level timing control like Formisch.
5. Async State Management Overhead
Explanation: React Hook Form does not provide built-in async validation state. Developers must manually manage loading indicators and disable submit buttons during async checks, increasing boilerplate and error potential.
Fix: Use a library with built-in async state management, such as TanStack Form's isValidating or Formisch's schema-integrated async handling. If using RHF, create a custom hook wrapper to manage async state consistently.
6. Field Array Re-render Storms
Explanation: React Hook Form's useFieldArray can trigger expensive re-renders if parent components subscribe to formState or if the array is large. Manual optimization is required to maintain performance.
Fix: Memoize field array items and use granular subscriptions. Ensure parent components do not subscribe to broad state. Consider libraries with automatic render optimization, like TanStack Form or Formisch, for complex dynamic forms.
7. Schema-Component Coupling
Explanation: Placing validation logic directly in components creates tight coupling between UI and business rules. This makes it difficult to reuse validation logic across different forms or contexts.
Fix: Centralize validation logic in schemas or validator objects. Keep components focused on rendering and user interaction. Use schema-first libraries to enforce this separation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Legacy Codebase, Simple Forms | React Hook Form | Mature ecosystem, low migration cost, sufficient for basic needs. | Low |
| New TypeScript Project, Complex Validation | Formisch | Schema-first architecture eliminates type drift, built-in async handling, automatic render optimization. | Medium (Valibot dependency) |
| TanStack Ecosystem, Granular Control | TanStack Form | Consistent with TanStack patterns, reactive store, granular validation triggers. | Low |
| High Performance, React Native | Formisch / TanStack Form | Signal-based or reactive store models provide automatic granular updates, reducing manual optimization. | Low |
| Enterprise Scale, Type Safety Critical | Formisch | Single source of truth for types and validation reduces maintenance overhead and runtime errors. | Medium |
Configuration Template
Use this Valibot schema template to define a robust, type-safe form structure with async validation and cross-field rules.
import * as v from 'valibot';
// Define form schema with types, validation, and async rules
export const userFormSchema = v.object({
username: v.pipe(
v.string(v.minLength(3), 'Username must be at least 3 characters'),
v.checkAsync(async (username) => {
// Simulate async availability check
const isAvailable = await checkUsernameAvailability(username);
return isAvailable;
}, 'Username is taken')
),
email: v.pipe(
v.string(v.email('Invalid email format')),
v.checkAsync(async (email) => {
const isRegistered = await checkEmailRegistration(email);
return !isRegistered;
}, 'Email already registered')
),
profile: v.object({
firstName: v.string(v.minLength(1)),
lastName: v.string(v.minLength(1)),
bio: v.optional(v.string(v.maxLength(500))),
}),
password: v.string(v.minLength(8), 'Password must be at least 8 characters'),
confirmPassword: v.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords must match', path: ['confirmPassword'] }
);
// Helper functions for async checks
async function checkUsernameAvailability(username: string): Promise<boolean> {
// Implementation
return true;
}
async function checkEmailRegistration(email: string): Promise<boolean> {
// Implementation
return false;
}
Quick Start Guide
- Define Schema: Create a Valibot schema that includes all form fields, validation rules, and async checks.
- Initialize Form: Use
useForm from Formisch with the schema to create a form context. Types are inferred automatically.
- Bind Fields: Connect form fields to the context using the provided bindings. Validation and state management are handled by the library.
- Handle Submit: Implement the submit handler to process validated data. Async state and validation timing are managed internally.
- Test and Iterate: Verify type safety, validation behavior, and performance. Adjust schema rules as needed without modifying component logic.