Forms & Validation
Controlled Forms - Cơ bản
import { useState, FormEvent } from 'react';
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const [form, setForm] = useState<LoginForm>({
email: '',
password: '',
rememberMe: false,
});
const [errors, setErrors] = useState<Partial<LoginForm>>({});
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error khi user sửa
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
function validate(): boolean {
const newErrors: Partial<Record<keyof LoginForm, string>> = {};
if (!form.email) newErrors.email = 'Email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
newErrors.email = 'Invalid email format';
if (!form.password) newErrors.password = 'Password is required';
else if (form.password.length < 8)
newErrors.password = 'Password must be at least 8 characters';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!validate()) return;
await login(form);
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
aria-invalid={!!errors.password}
/>
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<label>
<input
name="rememberMe"
type="checkbox"
checked={form.rememberMe}
onChange={handleChange}
/>
Remember me
</label>
<button type="submit">Login</button>
</form>
);
}
React Hook Form
Thư viện form mạnh nhất, hiệu suất cao vì không re-render mỗi keystroke.
npm install react-hook-form
import { useForm, SubmitHandler } from 'react-hook-form';
interface RegisterForm {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
}
function RegisterForm() {
const {
register, // Kết nối input với form
handleSubmit, // Wrap submit handler
formState: { errors, isSubmitting, isValid },
watch, // Theo dõi giá trị field
reset, // Reset form
setError, // Set error thủ công
getValues, // Lấy giá trị hiện tại
} = useForm<RegisterForm>({
mode: 'onBlur', // Validate khi blur (tùy chọn: onChange, onSubmit)
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
age: 18,
},
});
const password = watch('password'); // Theo dõi giá trị password
const onSubmit: SubmitHandler<RegisterForm> = async (data) => {
try {
await registerUser(data);
reset();
} catch (err) {
// Set server-side error
setError('email', {
type: 'server',
message: 'Email already exists',
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label>Username</label>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Minimum 3 characters' },
maxLength: { value: 20, message: 'Maximum 20 characters' },
pattern: {
value: /^[a-zA-Z0-9_]+$/,
message: 'Only letters, numbers and underscore',
},
})}
/>
{errors.username && <span>{errors.username.message}</span>}
</div>
<div>
<label>Email</label>
<input
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email',
},
})}
/>
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label>Password</label>
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Minimum 8 characters' },
})}
/>
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<label>Confirm Password</label>
<input
type="password"
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
/>
{errors.confirmPassword && (
<span>{errors.confirmPassword.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
);
}
React Hook Form + Zod
Kết hợp React Hook Form với Zod schema validation.
npm install zod @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Định nghĩa schema
const productSchema = z.object({
name: z
.string()
.min(1, 'Name is required')
.max(100, 'Name too long'),
price: z
.number({ invalid_type_error: 'Price must be a number' })
.positive('Price must be positive')
.multipleOf(0.01, 'Max 2 decimal places'),
category: z.enum(['electronics', 'clothing', 'food'], {
errorMap: () => ({ message: 'Please select a category' }),
}),
description: z.string().optional(),
inStock: z.boolean().default(true),
tags: z.array(z.string()).min(1, 'At least one tag required'),
});
// Infer TypeScript type từ schema
type ProductFormData = z.infer<typeof productSchema>;
function ProductForm() {
const {
register,
handleSubmit,
formState: { errors },
control,
} = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
defaultValues: {
inStock: true,
tags: [],
},
});
const onSubmit = async (data: ProductFormData) => {
// data đã được validated và typed!
await createProduct(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
placeholder="Product name"
{...register('name')}
/>
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input
type="number"
step="0.01"
placeholder="Price"
{...register('price', { valueAsNumber: true })}
/>
{errors.price && <span>{errors.price.message}</span>}
</div>
<div>
<select {...register('category')}>
<option value="">Select category</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="food">Food</option>
</select>
{errors.category && <span>{errors.category.message}</span>}
</div>
<label>
<input type="checkbox" {...register('inStock')} />
In Stock
</label>
<button type="submit">Save Product</button>
</form>
);
}
useFieldArray - Dynamic Fields
import { useForm, useFieldArray, Controller } from 'react-hook-form';
interface OrderForm {
customerName: string;
items: {
productId: string;
quantity: number;
price: number;
}[];
}
function OrderForm() {
const { register, control, handleSubmit, watch } = useForm<OrderForm>({
defaultValues: {
customerName: '',
items: [{ productId: '', quantity: 1, price: 0 }],
},
});
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
});
const items = watch('items');
const total = items.reduce((sum, item) => sum + item.quantity * item.price, 0);
return (
<form onSubmit={handleSubmit(console.log)}>
<input
placeholder="Customer name"
{...register('customerName', { required: true })}
/>
{fields.map((field, index) => (
<div key={field.id} className="order-item">
<input
placeholder="Product ID"
{...register(`items.${index}.productId`, { required: true })}
/>
<input
type="number"
min={1}
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
min: 1,
})}
/>
<input
type="number"
step="0.01"
{...register(`items.${index}.price`, { valueAsNumber: true })}
/>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ productId: '', quantity: 1, price: 0 })}
>
+ Add Item
</button>
<p>Total: ${total.toFixed(2)}</p>
<button type="submit">Place Order</button>
</form>
);
}