Aller au contenu principal

Baldr Dashboard - Form Patterns Guide

Version: 1.0.0
Last Updated: October 28, 2025
Maintainer: Development Team


Table of Contents

  1. Introduction
  2. Form Architecture
  3. React Hook Form Integration
  4. Zod Validation
  5. Form Components
  6. Error Handling
  7. Accessibility
  8. Form Submission
  9. Common Patterns
  10. Advanced Patterns
  11. Performance Optimization
  12. Testing Forms
  13. Best Practices
  14. 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

  1. Use TypeScript for type safety

    type FormData = z.infer<typeof schema>;
  2. Define schemas outside components

    const schema = z.object({...}); // Outside component

    export default function Form() { ... }
  3. 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'
  4. Handle loading and error states

    <Button type="submit" disabled={isSubmitting}>
    {isSubmitting ? 'Submitting...' : 'Submit'}
    </Button>
  5. Reset form after successful submission

    onSuccess: () => {
    reset();
    toast.success('Form submitted successfully');
    }

❌ Don'ts

  1. 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')} />
  2. Don't validate on every keystroke

    // ❌ Bad for UX
    mode: 'onChange'

    // ✅ Good
    mode: 'onBlur'
  3. 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