Baldr Dashboard - Form Patterns Guide
Version: 1.0.0
Last Updated: October 28, 2025
Maintainer: Development Team
Table of Contents
- Introduction
- Form Architecture
- React Hook Form Integration
- Zod Validation
- Form Components
- Error Handling
- Accessibility
- Form Submission
- Common Patterns
- Advanced Patterns
- Performance Optimization
- Testing Forms
- Best Practices
- Troubleshooting
Introduction
This guide documents form handling patterns in the Baldr Dashboard. We use React Hook Form for form state management and Zod for schema validation.
Tech Stack
- React Hook Form: Form state management and validation
- Zod: TypeScript-first schema validation
- Custom Form Components: Molecule-level form fields
Why React Hook Form + Zod?
Benefits:
- ✅ Type-safe validation with TypeScript
- ✅ Excellent performance (uncontrolled inputs)
- ✅ Small bundle size
- ✅ Simple API with minimal re-renders
- ✅ Built-in error handling
- ✅ Easy integration with custom components
Form Architecture
Form Component Layers
graph TD
P[Page/Route] --> F[Form Container]
F --> V[Validation Schema - Zod]
F --> RF[React Hook Form]
RF --> M1[InputField Molecule]
RF --> M2[CheckboxField Molecule]
RF --> M3[PasswordInputField Molecule]
M1 --> A1[Input Atom]
M2 --> A2[Checkbox Atom]
M3 --> A3[Input Atom]
File Structure
app/
├── schemas/ # Zod validation schemas
│ ├── user.schema.ts
│ ├── module.schema.ts
│ └── ...
├── components/
│ ├── molecules/ # Form field components
│ │ ├── inputField.molecule.tsx
│ │ ├── checkboxField.molecule.tsx
│ │ └── passwordInputField.molecule.tsx
│ └── organisms/ # Complex forms
│ ├── userForm.organism.tsx
│ └── ...
└── pages/ # Pages with forms
├── userCreate.page.tsx
└── ...
React Hook Form Integration
Basic Form Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define validation schema
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
rememberMe: z.boolean().optional(),
});
// Infer TypeScript type from schema
type FormData = z.infer<typeof formSchema>;
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: FormData) => {
try {
await loginUser(data);
// Handle success
} catch (error) {
// Handle error
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
useForm Hook Options
const {
register, // Register input fields
handleSubmit, // Handle form submission
watch, // Watch field values
setValue, // Set field values programmatically
reset, // Reset form to default values
getValues, // Get current form values
trigger, // Trigger validation manually
formState: {
errors, // Validation errors
isSubmitting, // Submission state
isDirty, // Form has been modified
isValid, // Form is valid
touchedFields, // Fields that have been touched
dirtyFields, // Fields that have been modified
},
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {},
mode: 'onBlur', // When to validate: 'onBlur' | 'onChange' | 'onSubmit'
reValidateMode: 'onChange', // When to revalidate after error
});
Zod Validation
Schema Definitions
import { z } from 'zod';
// Basic schema
const userSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
age: z.number().min(18, 'Must be at least 18 years old').optional(),
});
// Complex schema with refinements
const passwordSchema = z.object({
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
// Schema with custom validation
const dateSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine((data) => data.endDate > data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
});
// Optional and nullable fields
const optionalSchema = z.object({
requiredField: z.string(),
optionalField: z.string().optional(),
nullableField: z.string().nullable(),
optionalNullableField: z.string().optional().nullable(),
});
// Arrays
const arraySchema = z.object({
tags: z.array(z.string()).min(1, 'At least one tag is required'),
categories: z.array(z.object({
id: z.string(),
name: z.string(),
})),
});
// Enums
const statusSchema = z.object({
status: z.enum(['active', 'inactive', 'pending'], {
errorMap: () => ({ message: 'Invalid status' }),
}),
});
// Unions
const contactSchema = z.object({
contactMethod: z.union([
z.object({ type: z.literal('email'), email: z.string().email() }),
z.object({ type: z.literal('phone'), phone: z.string() }),
]),
});
Common Validation Patterns
// Email validation
email: z.string().email('Invalid email address')
// URL validation
website: z.string().url('Invalid URL')
// UUID validation
id: z.string().uuid('Invalid ID format')
// Phone number (French format)
phone: z.string().regex(
/^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/,
'Invalid French phone number'
)
// Postal code (French format)
postalCode: z.string().regex(
/^[0-9]{5}$/,
'Invalid postal code (5 digits required)'
)
// Password strength
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must not exceed 100 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character')
// File size validation (in bytes)
fileSize: z.number().max(5_000_000, 'File size must not exceed 5MB')
// Date range validation
dateRange: z.object({
start: z.date(),
end: z.date(),
}).refine(
(data) => data.end >= data.start,
'End date must be after or equal to start date'
)
// Conditional validation
z.object({
hasAddress: z.boolean(),
address: z.string().optional(),
}).refine(
(data) => !data.hasAddress || !!data.address,
{
message: 'Address is required when "Has Address" is checked',
path: ['address'],
}
)
Reusable Schemas
// app/schemas/common.schema.ts
export const emailSchema = z.string().email('Invalid email address');
export const passwordSchema = z.string().min(8, 'Password must be at least 8 characters');
export const phoneSchema = z.string().regex(
/^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/,
'Invalid phone number'
);
// app/schemas/user.schema.ts
import { emailSchema, passwordSchema } from './common.schema';
export const userCreateSchema = z.object({
email: emailSchema,
password: passwordSchema,
name: z.string().min(2, 'Name must be at least 2 characters'),
role: z.enum(['admin', 'user', 'guest']),
});
export const userUpdateSchema = userCreateSchema.partial().extend({
id: z.string().uuid(),
});
export type UserCreateData = z.infer<typeof userCreateSchema>;
export type UserUpdateData = z.infer<typeof userUpdateSchema>;
Form Components
InputField Molecule
/**
* Standard text input field with label, error handling, and React Hook Form integration
*/
import { forwardRef } from 'react';
import { UseFormRegisterReturn } from 'react-hook-form';
import Input from '../atoms/input.atom';
import Paragraph from '../atoms/paragraph.atom';
interface InputFieldProps {
label: string;
name: string;
type?: string;
placeholder?: string;
error?: string;
required?: boolean;
helpText?: string;
disabled?: boolean;
register?: UseFormRegisterReturn;
}
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
({ label, name, type = 'text', placeholder, error, required, helpText, disabled, register }, ref) => {
const id = `input-${name}`;
return (
<div className="mb-4">
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
id={id}
type={type}
placeholder={placeholder}
invalid={!!error}
disabled={disabled}
{...register}
ref={ref}
/>
{helpText && !error && (
<Paragraph size="sm" className="mt-1">
{helpText}
</Paragraph>
)}
{error && (
<Paragraph size="sm" error className="mt-1">
{error}
</Paragraph>
)}
</div>
);
}
);
InputField.displayName = 'InputField';
export default InputField;
Usage with React Hook Form
export default function UserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Basic usage */}
<InputField
label="Email"
name="email"
type="email"
placeholder="user@example.com"
required
error={errors.email?.message}
register={register('email')}
/>
{/* With help text */}
<InputField
label="Username"
name="username"
placeholder="johndoe"
helpText="This will be visible to other users"
error={errors.username?.message}
register={register('username')}
/>
{/* Disabled field */}
<InputField
label="User ID"
name="id"
disabled
register={register('id')}
/>
<Button type="submit">Submit</Button>
</form>
);
}
CheckboxField Molecule
interface CheckboxFieldProps {
label: string;
name: string;
error?: string;
disabled?: boolean;
register?: UseFormRegisterReturn;
}
export default function CheckboxField({
label,
name,
error,
disabled,
register,
}: CheckboxFieldProps) {
const id = `checkbox-${name}`;
return (
<div className="mb-4">
<div className="flex items-center gap-2">
<Checkbox
inputId={id}
disabled={disabled}
{...register}
/>
<label htmlFor={id} className="text-sm text-gray-700">
{label}
</label>
</div>
{error && (
<Paragraph size="sm" error className="mt-1">
{error}
</Paragraph>
)}
</div>
);
}
// Usage
<CheckboxField
label="I agree to the terms and conditions"
name="terms"
error={errors.terms?.message}
register={register('terms')}
/>
PasswordInputField Molecule
import { useState } from 'react';
export default function PasswordInputField({
label,
name,
error,
required,
register,
}: PasswordInputFieldProps) {
const [showPassword, setShowPassword] = useState(false);
const id = `password-${name}`;
return (
<div className="mb-4">
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative">
<Input
id={id}
type={showPassword ? 'text' : 'password'}
invalid={!!error}
{...register}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<span className="material-symbols-outlined">
{showPassword ? 'visibility_off' : 'visibility'}
</span>
</button>
</div>
{error && (
<Paragraph size="sm" error className="mt-1">
{error}
</Paragraph>
)}
</div>
);
}
// Usage
<PasswordInputField
label="Password"
name="password"
required
error={errors.password?.message}
register={register('password')}
/>
Error Handling
Displaying Validation Errors
export default function Form() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Field-level errors */}
<InputField
label="Email"
name="email"
error={errors.email?.message}
register={register('email')}
/>
{/* Form-level error summary */}
{Object.keys(errors).length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<p className="text-red-800 font-semibold mb-2">
Please fix the following errors:
</p>
<ul className="list-disc list-inside text-red-700">
{Object.entries(errors).map(([field, error]) => (
<li key={field}>{error.message}</li>
))}
</ul>
</div>
)}
<Button type="submit">Submit</Button>
</form>
);
}
Server-Side Errors
export default function Form() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
try {
await createUser(data);
} catch (error: any) {
// Handle field-specific errors
if (error.fieldErrors) {
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as keyof FormData, {
type: 'server',
message: message as string,
});
});
}
// Handle form-level errors
if (error.message) {
setError('root', {
type: 'server',
message: error.message,
});
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<p className="text-red-800">{errors.root.message}</p>
</div>
)}
{/* Form fields */}
</form>
);
}
Accessibility
Form Accessibility Patterns
export default function AccessibleForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form
onSubmit={handleSubmit(onSubmit)}
aria-label="User registration form"
>
{/* Field with proper ARIA attributes */}
<div className="mb-4">
<label htmlFor="email" id="email-label">
Email Address
<span aria-label="required">*</span>
</label>
<input
id="email"
type="email"
aria-labelledby="email-label"
aria-describedby={errors.email ? 'email-error' : 'email-help'}
aria-invalid={!!errors.email}
aria-required="true"
{...register('email')}
/>
{!errors.email && (
<p id="email-help" className="text-sm text-gray-600">
We'll never share your email
</p>
)}
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Submit button with loading state */}
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
aria-live="polite"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
{/* Screen reader announcements */}
{isSubmitting && (
<div role="status" aria-live="polite" className="sr-only">
Submitting form, please wait...
</div>
)}
</form>
);
}
Keyboard Navigation
export default function Form() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLFormElement>) => {
// Submit on Ctrl+Enter
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleSubmit(onSubmit)();
}
};
return (
<form onKeyDown={handleKeyDown}>
{/* Form fields */}
</form>
);
}
Form Submission
Basic Submission
export default function Form() {
const {
register,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
try {
await api.createUser(data);
// Show success message
toast.success('User created successfully');
// Redirect
navigate('/users');
} catch (error) {
// Handle error
toast.error('Failed to create user');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create User'}
</Button>
</form>
);
}
With TanStack Query Mutation
import { useMutation } from '@tanstack/react-query';
export default function Form() {
const {
register,
handleSubmit,
reset,
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const mutation = useMutation({
mutationFn: (data: FormData) => api.createUser(data),
onSuccess: () => {
toast.success('User created successfully');
reset(); // Reset form
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
toast.error(error.message);
},
});
const onSubmit = (data: FormData) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
<Button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create User'}
</Button>
</form>
);
}
Multi-Step Forms
export default function MultiStepForm() {
const [step, setStep] = useState(1);
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur',
});
const handleNextStep = async () => {
// Validate current step fields
const isValid = await trigger(getFieldsForStep(step));
if (isValid) {
setStep(step + 1);
}
};
const handlePreviousStep = () => {
setStep(step - 1);
};
const onSubmit = async (data: FormData) => {
// Submit form
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Progress indicator */}
<div className="mb-6">
<p className="text-sm text-gray-600">Step {step} of 3</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="bg-primary-700 h-2 rounded-full transition-all"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
</div>
{/* Step 1: Personal Info */}
{step === 1 && (
<div>
<h2>Personal Information</h2>
<InputField
label="Name"
name="name"
error={errors.name?.message}
register={register('name')}
/>
<InputField
label="Email"
name="email"
type="email"
error={errors.email?.message}
register={register('email')}
/>
</div>
)}
{/* Step 2: Account Details */}
{step === 2 && (
<div>
<h2>Account Details</h2>
<InputField
label="Username"
name="username"
error={errors.username?.message}
register={register('username')}
/>
<PasswordInputField
label="Password"
name="password"
error={errors.password?.message}
register={register('password')}
/>
</div>
)}
{/* Step 3: Review */}
{step === 3 && (
<div>
<h2>Review Your Information</h2>
{/* Display summary */}
</div>
)}
{/* Navigation buttons */}
<div className="flex justify-between mt-6">
{step > 1 && (
<Button
type="button"
variant="secondary"
onClick={handlePreviousStep}
>
Previous
</Button>
)}
{step < 3 ? (
<Button
type="button"
onClick={handleNextStep}
>
Next
</Button>
) : (
<Button type="submit">
Submit
</Button>
)}
</div>
</form>
);
}
Common Patterns
Dynamic Form Fields
export default function DynamicFieldsForm() {
const { register, control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
contacts: [{ name: '', email: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2 mb-4">
<InputField
label="Name"
name={`contacts.${index}.name`}
register={register(`contacts.${index}.name`)}
/>
<InputField
label="Email"
name={`contacts.${index}.email`}
type="email"
register={register(`contacts.${index}.email`)}
/>
<Button
type="button"
variant="danger"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
onClick={() => append({ name: '', email: '' })}
>
Add Contact
</Button>
<Button type="submit">Submit</Button>
</form>
);
}
Conditional Fields
export default function ConditionalForm() {
const { register, watch, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
});
const hasCompany = watch('hasCompany');
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CheckboxField
label="I have a company"
name="hasCompany"
register={register('hasCompany')}
/>
{hasCompany && (
<>
<InputField
label="Company Name"
name="companyName"
register={register('companyName')}
/>
<InputField
label="VAT Number"
name="vatNumber"
register={register('vatNumber')}
/>
</>
)}
<Button type="submit">Submit</Button>
</form>
);
}
File Upload Forms
const fileSchema = z.object({
file: z
.instanceof(FileList)
.refine((files) => files.length > 0, 'File is required')
.refine(
(files) => files[0]?.size <= 5_000_000,
'File size must not exceed 5MB'
)
.refine(
(files) => ['image/jpeg', 'image/png'].includes(files[0]?.type),
'Only JPEG and PNG files are allowed'
),
});
export default function FileUploadForm() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<{ file: FileList }>({
resolver: zodResolver(fileSchema),
});
const file = watch('file');
const preview = file?.[0] ? URL.createObjectURL(file[0]) : null;
const onSubmit = async (data: { file: FileList }) => {
const formData = new FormData();
formData.append('file', data.file[0]);
await uploadFile(formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<label htmlFor="file">Upload File</label>
<input
id="file"
type="file"
accept="image/jpeg,image/png"
{...register('file')}
/>
{errors.file && (
<p className="text-red-600 text-sm">{errors.file.message}</p>
)}
</div>
{preview && (
<div className="mb-4">
<img src={preview} alt="Preview" className="max-w-xs" />
</div>
)}
<Button type="submit">Upload</Button>
</form>
);
}
Advanced Patterns
Form with Optimistic Updates
export default function OptimisticForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user', id] });
// Snapshot previous value
const previousUser = queryClient.getQueryData(['user', id]);
// Optimistically update
queryClient.setQueryData(['user', id], newData);
return { previousUser };
},
onError: (err, newData, context) => {
// Rollback on error
queryClient.setQueryData(['user', id], context?.previousUser);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['user', id] });
},
});
return (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
{/* Form fields */}
</form>
);
}
Auto-Save Form
export default function AutoSaveForm() {
const { register, watch } = useForm<FormData>();
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const debouncedSave = useMemo(
() =>
debounce(async (data: FormData) => {
await saveDraft(data);
setLastSaved(new Date());
}, 1000),
[]
);
useEffect(() => {
const subscription = watch((data) => {
debouncedSave(data as FormData);
});
return () => subscription.unsubscribe();
}, [watch, debouncedSave]);
return (
<form>
{lastSaved && (
<p className="text-sm text-gray-600">
Last saved: {lastSaved.toLocaleTimeString()}
</p>
)}
{/* Form fields */}
</form>
);
}
Performance Optimization
Memoize Validation Schemas
// ✅ Good: Define schema outside component
const userSchema = z.object({
email: z.string().email(),
name: z.string(),
});
export default function Form() {
const { register } = useForm({
resolver: zodResolver(userSchema), // Schema not recreated on render
});
// ...
}
// ❌ Bad: Schema recreated on every render
export default function Form() {
const userSchema = z.object({ // Created on every render
email: z.string().email(),
name: z.string(),
});
const { register } = useForm({
resolver: zodResolver(userSchema),
});
// ...
}
Debounce Validation
const { register } = useForm({
resolver: zodResolver(schema),
mode: 'onChange', // Validate on every change
reValidateMode: 'onChange',
delayError: 500, // Delay showing errors by 500ms
});
Use Uncontrolled Components
// ✅ Good: Uncontrolled (React Hook Form default)
<input {...register('email')} />
// ❌ Bad: Controlled (causes re-renders)
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
Testing Forms
Testing Form Validation
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('shows validation errors for empty fields', async () => {
render(<LoginForm />);
const submitButton = screen.getByRole('button', { name: /submit/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
});
it('shows error for invalid email', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText(/email/i);
await userEvent.type(emailInput, 'invalid-email');
const submitButton = screen.getByRole('button', { name: /submit/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Invalid email address')).toBeInTheDocument();
});
});
it('submits form with valid data', async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'user@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
});
Best Practices
✅ Do's
-
Use TypeScript for type safety
type FormData = z.infer<typeof schema>; -
Define schemas outside components
const schema = z.object({...}); // Outside component
export default function Form() { ... } -
Use Zod for validation instead of custom validators
// ✅ Good
email: z.string().email('Invalid email')
// ❌ Bad
validate: (value) => /\S+@\S+\.\S+/.test(value) || 'Invalid email' -
Handle loading and error states
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button> -
Reset form after successful submission
onSuccess: () => {
reset();
toast.success('Form submitted successfully');
}
❌ Don'ts
-
Don't use controlled inputs unnecessarily
// ❌ Bad: Causes re-renders
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// ✅ Good: Uncontrolled
<input {...register('email')} /> -
Don't validate on every keystroke
// ❌ Bad for UX
mode: 'onChange'
// ✅ Good
mode: 'onBlur' -
Don't forget accessibility
// ❌ Bad
<input name="email" />
// ✅ Good
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
Troubleshooting
Common Issues
Issue: Form doesn't submit
// Problem: Missing handleSubmit wrapper
<form onSubmit={onSubmit}> // ❌ Wrong
// Solution: Wrap with handleSubmit
<form onSubmit={handleSubmit(onSubmit)}> // ✅ Correct
Issue: Validation errors not showing
// Problem: Not accessing errors correctly
{errors.email} // ❌ Wrong (shows object)
// Solution: Access error message
{errors.email?.message} // ✅ Correct
Issue: Default values not working
// Problem: Setting defaults after useForm
const { register } = useForm();
setValue('email', 'default@example.com'); // ❌ Wrong timing
// Solution: Set in useForm options
const { register } = useForm({
defaultValues: {
email: 'default@example.com', // ✅ Correct
},
});
Conclusion
Following these form patterns ensures:
- Type Safety with TypeScript and Zod
- Performance with React Hook Form's uncontrolled inputs
- Accessibility with proper ARIA attributes
- User Experience with clear validation and feedback
- Maintainability with consistent patterns
Questions or Suggestions?
Contact the development team or create an issue in the repository.
Last Updated: October 28, 2025
Version: 1.0.0