Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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>
  );
}