Skip to content

📅 NGÀY 43: Form Validation Schemas với Zod

Tóm tắt: Hôm nay chúng ta học cách sử dụng Zod để định nghĩa validation schemas type-safe cho forms. Zod giúp tách biệt validation logic khỏi UI, cung cấp IntelliSense tự động, và tích hợp hoàn hảo với React Hook Form. Chúng ta sẽ xây dựng schemas từ đơn giản đến phức tạp, custom error messages, và hiểu rõ khi nào nên dùng schema-based validation thay vì inline validation.

🎯 Mục tiêu học tập (5 phút)

  • [ ] Hiểu được schema-based validation là gì và tại sao cần thiết
  • [ ] Viết được Zod schemas cho các use cases phổ biến (string, number, email, nested objects, arrays)
  • [ ] Tích hợp Zod với React Hook Form sử dụng zodResolver
  • [ ] Custom error messages và localization
  • [ ] So sánh được trade-offs giữa inline validation vs schema validation

🤔 Kiểm tra đầu vào (5 phút)

  1. React Hook Form validation: Trong Ngày 42, bạn đã học cách validate như thế nào? register("email", { required: true, pattern: /.../ }) có nhược điểm gì?

  2. Type safety: Khi dùng watch() hoặc getValues(), làm sao TypeScript biết type của form data? Có cách nào để có auto-complete không?

  3. Reusability: Nếu bạn có 5 forms đều cần validate email giống nhau, bạn sẽ làm gì để tránh duplicate code?


📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)

1.1 Vấn Đề Thực Tế

Bạn đang build một ứng dụng e-commerce với nhiều forms: registration, checkout, profile settings, product reviews. Mỗi form đều cần validate email, phone, address...

Vấn đề với inline validation:

jsx
// ❌ Registration form
<input {...register("email", {
  required: "Email is required",
  pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email" }
})} />

// ❌ Checkout form - duplicate validation logic
<input {...register("email", {
  required: "Email is required",
  pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email" }
})} />

// ❌ Profile form - again...
<input {...register("email", {
  required: "Email is required",
  pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email" }
})} />

Những vấn đề:

  • ❌ Duplicate validation logic khắp nơi
  • ❌ Không có type safety - TypeScript không biết shape của form data
  • ❌ Khó maintain khi validation rules thay đổi
  • ❌ Không có auto-complete khi dùng watch(), getValues()
  • ❌ Validation logic lẫn lộn với UI code

1.2 Giải Pháp: Schema-based Validation với Zod

Zod là một TypeScript-first schema validation library cho phép bạn:

  • ✅ Định nghĩa validation rules một lần, dùng nhiều nơi
  • ✅ Tự động infer TypeScript types từ schema
  • ✅ Tách biệt validation logic khỏi UI
  • ✅ Custom error messages dễ dàng
  • ✅ Compose và reuse schemas
jsx
// ✅ Define schema once
const userSchema = z.object({
  email: z.string().email("Invalid email address"),
  age: z.number().min(18, "Must be 18+"),
});

// Type được infer tự động!
type UserFormData = z.infer<typeof userSchema>;
// { email: string; age: number; }

// Use everywhere với type safety
const { register, handleSubmit } = useForm<UserFormData>({
  resolver: zodResolver(userSchema)
});

1.3 Mental Model

┌─────────────────────────────────────────────────────────┐
│                   ZOD SCHEMA FLOW                       │
└─────────────────────────────────────────────────────────┘

1. DEFINE SCHEMA (Single Source of Truth)
   ┌──────────────────────────────────┐
   │ const schema = z.object({        │
   │   email: z.string().email(),     │ ◄── Define once
   │   age: z.number().min(18)        │
   │ })                               │
   └──────────────────────────────────┘

                  ├─► TypeScript Type (auto-inferred)
                  │   type FormData = z.infer<typeof schema>

                  └─► Runtime Validation
                      schema.parse(data) → valid or throw error

2. INTEGRATE WITH RHF
   ┌──────────────────────────────────┐
   │ useForm({                        │
   │   resolver: zodResolver(schema)  │ ◄── Bridge
   │ })                               │
   └──────────────────────────────────┘


   User Input → Zod Validate → RHF State → Submit


3. REUSABILITY
   ┌─────────────┐
   │ emailSchema │ ──┬─► Registration Form
   └─────────────┘   ├─► Checkout Form
                     └─► Profile Form

Analogy: Schema như blueprint của một ngôi nhà

  • Blueprint (schema) định nghĩa rules: phòng phải rộng ít nhất 10m², cửa cao ít nhất 2m...
  • Mọi ngôi nhà (form) xây theo blueprint đều tuân thủ rules
  • Nếu thay đổi blueprint, mọi ngôi nhà mới đều follow rules mới
  • Architect (TypeScript) biết chính xác structure từ blueprint

1.4 Hiểu Lầm Phổ Biến

"Schema validation chỉ cho backend, frontend dùng inline validation là đủ"

  • ✅ Schema validation giúp DRY (Don't Repeat Yourself), type safety, và dễ maintain hơn nhiều

"Zod làm bundle size lớn hơn, không nên dùng"

  • ✅ Zod chỉ ~8KB gzipped, nhưng tiết kiệm được hàng trăm dòng validation code duplicate

"Schema phức tạp, khó học"

  • ✅ Zod API rất intuitive: z.string().email().min(5) - đọc như tiếng Anh

"Dùng schema thì không custom được error messages"

  • ✅ Zod cho phép custom messages ở mọi level: global, per-field, per-rule

💻 PHẦN 2: LIVE CODING (45 phút)

Demo 1: Zod Basics - Primitive Types ⭐

jsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * Demo: Basic Zod schema với primitive types
 */

// ❌ BEFORE: Inline validation (not reusable, no type safety)
function LoginFormBefore() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input
        {...register("email", {
          required: "Email required",
          pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: "Invalid email"
          }
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      <input
        type="password"
        {...register("password", {
          required: "Password required",
          minLength: { value: 8, message: "Min 8 chars" }
        })}
      />
      {errors.password && <span>{errors.password.message}</span>}
    </form>
  );
}

// ✅ AFTER: Schema validation (reusable, type-safe)
const loginSchema = z.object({
  email: z.string()
    .min(1, "Email is required")
    .email("Invalid email address"),
  password: z.string()
    .min(8, "Password must be at least 8 characters")
});

// Type tự động infer!
type LoginFormData = z.infer<typeof loginSchema>;
// { email: string; password: string; }

function LoginFormAfter() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema)
  });

  const onSubmit = (data: LoginFormData) => {
    console.log(data); // TypeScript knows exact shape!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register("email")} placeholder="Email" />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <input
          type="password"
          {...register("password")}
          placeholder="Password"
        />
        {errors.password && <span>{errors.password.message}</span>}
      </div>

      <button type="submit">Login</button>
    </form>
  );
}

// 🎯 BENEFITS:
// 1. Auto-complete khi gõ data.email, data.password
// 2. Schema có thể reuse cho API validation
// 3. Dễ test: loginSchema.parse({ email: "test", password: "123" })

Demo 2: Nested Objects & Arrays ⭐⭐

jsx
import { z } from 'zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * Demo: Complex schema với nested objects và arrays
 * Use case: User profile với multiple addresses
 */

// Define reusable schemas
const addressSchema = z.object({
  street: z.string().min(1, "Street is required"),
  city: z.string().min(1, "City is required"),
  zipCode: z.string().regex(/^\d{5}$/, "Invalid ZIP code"),
  isDefault: z.boolean().default(false)
});

const profileSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email"),
  age: z.number()
    .min(18, "Must be 18 or older")
    .max(120, "Invalid age"),

  // Nested object
  preferences: z.object({
    newsletter: z.boolean(),
    notifications: z.boolean()
  }),

  // Array of objects
  addresses: z.array(addressSchema)
    .min(1, "At least one address required")
    .max(5, "Maximum 5 addresses allowed")
});

type ProfileFormData = z.infer<typeof profileSchema>;

function ProfileForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors }
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      preferences: { newsletter: true, notifications: false },
      addresses: [{ street: '', city: '', zipCode: '', isDefault: true }]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "addresses"
  });

  const onSubmit = (data: ProfileFormData) => {
    console.log('Valid data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Basic fields */}
      <input {...register("name")} placeholder="Name" />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <input
        type="number"
        {...register("age", { valueAsNumber: true })}
        placeholder="Age"
      />
      {errors.age && <p>{errors.age.message}</p>}

      {/* Nested object */}
      <fieldset>
        <legend>Preferences</legend>
        <label>
          <input type="checkbox" {...register("preferences.newsletter")} />
          Newsletter
        </label>
        <label>
          <input type="checkbox" {...register("preferences.notifications")} />
          Notifications
        </label>
      </fieldset>

      {/* Array */}
      <fieldset>
        <legend>Addresses</legend>
        {fields.map((field, index) => (
          <div key={field.id}>
            <h4>Address {index + 1}</h4>

            <input
              {...register(`addresses.${index}.street`)}
              placeholder="Street"
            />
            {errors.addresses?.[index]?.street && (
              <p>{errors.addresses[index].street.message}</p>
            )}

            <input
              {...register(`addresses.${index}.city`)}
              placeholder="City"
            />
            {errors.addresses?.[index]?.city && (
              <p>{errors.addresses[index].city.message}</p>
            )}

            <input
              {...register(`addresses.${index}.zipCode`)}
              placeholder="ZIP Code"
            />
            {errors.addresses?.[index]?.zipCode && (
              <p>{errors.addresses[index].zipCode.message}</p>
            )}

            <label>
              <input
                type="checkbox"
                {...register(`addresses.${index}.isDefault`)}
              />
              Default address
            </label>

            {fields.length > 1 && (
              <button type="button" onClick={() => remove(index)}>
                Remove
              </button>
            )}
          </div>
        ))}

        {errors.addresses?.root && (
          <p>{errors.addresses.root.message}</p>
        )}

        <button
          type="button"
          onClick={() => append({
            street: '',
            city: '',
            zipCode: '',
            isDefault: false
          })}
        >
          Add Address
        </button>
      </fieldset>

      <button type="submit">Save Profile</button>
    </form>
  );
}

// Result: Type-safe form với complex nested structure!

Demo 3: Advanced Validation & Custom Rules ⭐⭐⭐

jsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * Demo: Advanced Zod features
 * - Conditional validation
 * - Custom validation functions
 * - Cross-field validation
 * - Transformations
 */

// ✅ Password confirmation validation
const registrationSchema = z.object({
  username: z.string()
    .min(3, "Username must be at least 3 characters")
    .max(20, "Username must be at most 20 characters")
    .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers and underscore allowed"),

  email: z.string().email("Invalid email address"),

  password: z.string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Must contain at least one uppercase letter")
    .regex(/[a-z]/, "Must contain at least one lowercase letter")
    .regex(/[0-9]/, "Must contain at least one number"),

  confirmPassword: z.string(),

  age: z.string()
    .transform(val => parseInt(val, 10)) // Transform string to number
    .pipe(z.number().min(18, "Must be 18+").max(120, "Invalid age")),

  // Conditional validation
  hasPromoCode: z.boolean(),
  promoCode: z.string().optional(),

  // Custom validation
  termsAccepted: z.boolean()
    .refine(val => val === true, {
      message: "You must accept the terms and conditions"
    })
})
.refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"] // Error sẽ hiện ở confirmPassword field
})
.refine(
  data => {
    // If hasPromoCode is true, promoCode must be provided
    if (data.hasPromoCode) {
      return data.promoCode && data.promoCode.length > 0;
    }
    return true;
  },
  {
    message: "Promo code is required when checkbox is selected",
    path: ["promoCode"]
  }
);

type RegistrationFormData = z.infer<typeof registrationSchema>;

function RegistrationForm() {
  const {
    register,
    watch,
    handleSubmit,
    formState: { errors }
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema)
  });

  const hasPromoCode = watch("hasPromoCode");

  const onSubmit = (data: RegistrationFormData) => {
    console.log('Registration data:', data);
    // data.age is number (transformed automatically!)
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register("username")} placeholder="Username" />
        {errors.username && <p>{errors.username.message}</p>}
      </div>

      <div>
        <input {...register("email")} placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <input
          type="password"
          {...register("password")}
          placeholder="Password"
        />
        {errors.password && <p>{errors.password.message}</p>}
      </div>

      <div>
        <input
          type="password"
          {...register("confirmPassword")}
          placeholder="Confirm Password"
        />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>

      <div>
        <input {...register("age")} placeholder="Age" />
        {errors.age && <p>{errors.age.message}</p>}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register("hasPromoCode")} />
          I have a promo code
        </label>
      </div>

      {hasPromoCode && (
        <div>
          <input {...register("promoCode")} placeholder="Promo Code" />
          {errors.promoCode && <p>{errors.promoCode.message}</p>}
        </div>
      )}

      <div>
        <label>
          <input type="checkbox" {...register("termsAccepted")} />
          I accept the terms and conditions
        </label>
        {errors.termsAccepted && <p>{errors.termsAccepted.message}</p>}
      </div>

      <button type="submit">Register</button>
    </form>
  );
}

// 🎯 KEY FEATURES DEMONSTRATED:
// 1. Cross-field validation (password === confirmPassword)
// 2. Conditional validation (promoCode required if hasPromoCode)
// 3. Custom validation (termsAccepted must be true)
// 4. Transformations (age string → number)
// 5. Complex regex patterns

🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)

⭐ Bài 1: Basic Schema Definition (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Tạo schema cơ bản cho contact form
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: Nested objects, arrays, custom validation
 *
 * Requirements:
 * 1. Name: required, min 2 chars
 * 2. Email: required, valid email format
 * 3. Phone: optional, but if provided must match format (XXX) XXX-XXXX
 * 4. Message: required, min 10 chars, max 500 chars
 *
 * 💡 Gợi ý:
 * - Dùng z.string().optional() cho optional fields
 * - Dùng z.string().regex() cho phone validation
 */

// ❌ CÁCH SAI: Inline validation không reusable
function ContactFormWrong() {
  const { register } = useForm();

  return (
    <form>
      <input
        {...register('name', {
          required: true,
          minLength: 2,
        })}
      />
      {/* Phải copy/paste validation logic mọi nơi */}
    </form>
  );
}

// ✅ CÁCH ĐÚNG: Schema-based validation
const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  phone: z
    .string()
    .regex(/^\(\d{3}\) \d{3}-\d{4}$/, 'Phone must be (XXX) XXX-XXXX format')
    .optional()
    .or(z.literal('')), // Allow empty string
  message: z
    .string()
    .min(10, 'Message must be at least 10 characters')
    .max(500, 'Message must be at most 500 characters'),
});

// 🎯 NHIỆM VỤ CỦA BẠN:
// TODO: Implement ContactForm component using the schema above
// TODO: Show validation errors below each field
// TODO: Log submitted data to console
💡 Solution
jsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * Contact form với Zod schema validation
 * @returns {JSX.Element} Contact form component
 */
const contactSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  phone: z.string()
    .regex(/^\(\d{3}\) \d{3}-\d{4}$/, "Phone must be (XXX) XXX-XXXX format")
    .optional()
    .or(z.literal("")),
  message: z.string()
    .min(10, "Message must be at least 10 characters")
    .max(500, "Message must be at most 500 characters")
});

type ContactFormData = z.infer<typeof contactSchema>;

function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema)
  });

  const onSubmit = (data: ContactFormData) => {
    console.log('Contact form data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Name *</label>
        <input {...register("name")} />
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </div>

      <div>
        <label>Email *</label>
        <input {...register("email")} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div>
        <label>Phone (optional)</label>
        <input {...register("phone")} placeholder="(123) 456-7890" />
        {errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
      </div>

      <div>
        <label>Message *</label>
        <textarea {...register("message")} rows={5} />
        {errors.message && <p style={{ color: 'red' }}>{errors.message.message}</p>}
      </div>

      <button type="submit">Send Message</button>
    </form>
  );
}

// Example usage:
// Valid: { name: "John", email: "john@example.com", phone: "", message: "Hello world!" }
// Invalid: { name: "J", email: "invalid", phone: "123", message: "Short" }

⭐⭐ Bài 2: Schema Composition (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Tạo reusable schemas và compose chúng
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Bạn có registration form và profile update form.
 * Cả hai đều cần email, name, nhưng registration cần password,
 * còn profile update cần bio.
 *
 * 🤔 PHÂN TÍCH:
 * Approach A: Tạo 2 schemas riêng biệt
 * Pros: Đơn giản, dễ hiểu
 * Cons: Duplicate email & name validation
 *
 * Approach B: Tạo base schema, extend cho mỗi use case
 * Pros: DRY, dễ maintain
 * Cons: Cần hiểu schema composition
 *
 * 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
 *
 * Sau đó implement 2 forms:
 * 1. RegistrationForm: name, email, password, confirmPassword
 * 2. ProfileUpdateForm: name, email, bio (optional)
 */

// ❌ APPROACH A: Duplicate schemas
const registrationSchemaA = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

const profileSchemaA = z.object({
  name: z.string().min(2), // Duplicate!
  email: z.string().email(), // Duplicate!
  bio: z.string().optional(),
});

// ✅ APPROACH B: Composed schemas
const baseUserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
});

const registrationSchemaB = baseUserSchema
  .extend({
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ['confirmPassword'],
  });

const profileSchemaB = baseUserSchema.extend({
  bio: z.string().max(500, 'Bio must be at most 500 characters').optional(),
});

// 🎯 NHIỆM VỤ CỦA BẠN:
// TODO: Chọn approach B và implement cả 2 forms
// TODO: Document lý do chọn approach B trong comment
💡 Solution
jsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * APPROACH B: Schema composition
 *
 * Lý do chọn:
 * 1. DRY - không duplicate validation logic
 * 2. Maintainability - thay đổi email validation ở 1 chỗ → affect tất cả
 * 3. Type safety - baseUserSchema có thể reuse cho API types
 * 4. Scalability - dễ thêm fields vào base schema sau này
 */

// Base schema - reusable
const baseUserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address")
});

// Registration schema - extends base
const registrationSchema = baseUserSchema.extend({
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"]
});

// Profile schema - extends base
const profileSchema = baseUserSchema.extend({
  bio: z.string().max(500, "Bio must be at most 500 characters").optional()
});

type RegistrationFormData = z.infer<typeof registrationSchema>;
type ProfileFormData = z.infer<typeof profileSchema>;

/**
 * Registration form component
 */
function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema)
  });

  const onSubmit = (data: RegistrationFormData) => {
    console.log('Registration:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Register</h2>

      <div>
        <input {...register("name")} placeholder="Name" />
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </div>

      <div>
        <input {...register("email")} placeholder="Email" />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div>
        <input type="password" {...register("password")} placeholder="Password" />
        {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
      </div>

      <div>
        <input type="password" {...register("confirmPassword")} placeholder="Confirm Password" />
        {errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>}
      </div>

      <button type="submit">Register</button>
    </form>
  );
}

/**
 * Profile update form component
 */
function ProfileUpdateForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      name: "John Doe",
      email: "john@example.com",
      bio: ""
    }
  });

  const onSubmit = (data: ProfileFormData) => {
    console.log('Profile update:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Update Profile</h2>

      <div>
        <input {...register("name")} placeholder="Name" />
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </div>

      <div>
        <input {...register("email")} placeholder="Email" />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div>
        <textarea {...register("bio")} placeholder="Bio (optional)" rows={4} />
        {errors.bio && <p style={{ color: 'red' }}>{errors.bio.message}</p>}
      </div>

      <button type="submit">Update Profile</button>
    </form>
  );
}

// Result:
// - baseUserSchema được reuse
// - Thay đổi email validation → affect cả 2 forms
// - Type-safe với auto-complete

⭐⭐⭐ Bài 3: Dynamic Form với Custom Validation (40 phút)

jsx
/**
 * 🎯 Mục tiêu: Build job application form với dynamic fields
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là recruiter, tôi muốn collect job applications với
 * flexible number of previous jobs và custom validation cho từng field"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Basic info: name, email, phone
 * - [ ] Current position & desired position
 * - [ ] Experience years: must match total years from previous jobs
 * - [ ] Previous jobs array: company, position, startDate, endDate
 * - [ ] Validate: endDate > startDate
 * - [ ] Validate: total experience = sum of all job durations
 * - [ ] Skills: comma-separated string, min 3 skills
 *
 * 🎨 Technical Constraints:
 * - Dùng useFieldArray cho previous jobs
 * - Custom validation function cho experience matching
 * - Transform skills string → array
 *
 * 🚨 Edge Cases cần handle:
 * - Empty previous jobs array
 * - Overlapping job dates
 * - Future end dates
 *
 * 📝 Implementation Checklist:
 * - [ ] Schema với nested validations
 * - [ ] Custom refine cho experience calculation
 * - [ ] Date validation
 * - [ ] Skills transformation
 * - [ ] Error messages clear & helpful
 */

// TODO: Implement schema and form
💡 Solution
jsx
import { z } from 'zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * Job application form với complex validation
 * @returns {JSX.Element} Job application form
 */

// Helper: Calculate years between two dates
const calculateYears = (start: string, end: string): number => {
  const startDate = new Date(start);
  const endDate = new Date(end);
  const diff = endDate.getTime() - startDate.getTime();
  return Math.floor(diff / (1000 * 60 * 60 * 24 * 365));
};

const previousJobSchema = z.object({
  company: z.string().min(1, "Company is required"),
  position: z.string().min(1, "Position is required"),
  startDate: z.string().min(1, "Start date is required"),
  endDate: z.string().min(1, "End date is required")
}).refine(
  data => {
    const start = new Date(data.startDate);
    const end = new Date(data.endDate);
    return end > start;
  },
  {
    message: "End date must be after start date",
    path: ["endDate"]
  }
).refine(
  data => {
    const end = new Date(data.endDate);
    const today = new Date();
    return end <= today;
  },
  {
    message: "End date cannot be in the future",
    path: ["endDate"]
  }
);

const jobApplicationSchema = z.object({
  // Basic info
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  phone: z.string().regex(/^\d{10}$/, "Phone must be 10 digits"),

  // Positions
  currentPosition: z.string().min(1, "Current position is required"),
  desiredPosition: z.string().min(1, "Desired position is required"),

  // Experience
  experienceYears: z.string()
    .transform(val => parseInt(val, 10))
    .pipe(z.number().min(0, "Experience must be 0 or more")),

  // Previous jobs
  previousJobs: z.array(previousJobSchema)
    .min(1, "At least one previous job required"),

  // Skills - transform comma-separated to array
  skills: z.string()
    .min(1, "Skills are required")
    .transform(val => val.split(',').map(s => s.trim()).filter(Boolean))
    .pipe(z.array(z.string()).min(3, "At least 3 skills required"))
}).refine(
  data => {
    // Calculate total experience from previous jobs
    const totalYears = data.previousJobs.reduce((sum, job) => {
      return sum + calculateYears(job.startDate, job.endDate);
    }, 0);

    return totalYears === data.experienceYears;
  },
  {
    message: "Total experience must match sum of previous job durations",
    path: ["experienceYears"]
  }
);

type JobApplicationData = z.infer<typeof jobApplicationSchema>;

function JobApplicationForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors }
  } = useForm<JobApplicationData>({
    resolver: zodResolver(jobApplicationSchema),
    defaultValues: {
      previousJobs: [
        { company: '', position: '', startDate: '', endDate: '' }
      ]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "previousJobs"
  });

  const onSubmit = (data: JobApplicationData) => {
    console.log('Application data:', data);
    // Note: skills is now an array after transformation!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Job Application</h2>

      {/* Basic Info */}
      <fieldset>
        <legend>Basic Information</legend>

        <div>
          <input {...register("name")} placeholder="Full Name" />
          {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
        </div>

        <div>
          <input {...register("email")} placeholder="Email" />
          {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
        </div>

        <div>
          <input {...register("phone")} placeholder="Phone (10 digits)" />
          {errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
        </div>
      </fieldset>

      {/* Positions */}
      <fieldset>
        <legend>Position Information</legend>

        <div>
          <input {...register("currentPosition")} placeholder="Current Position" />
          {errors.currentPosition && <p style={{ color: 'red' }}>{errors.currentPosition.message}</p>}
        </div>

        <div>
          <input {...register("desiredPosition")} placeholder="Desired Position" />
          {errors.desiredPosition && <p style={{ color: 'red' }}>{errors.desiredPosition.message}</p>}
        </div>

        <div>
          <input {...register("experienceYears")} placeholder="Total Experience (years)" />
          {errors.experienceYears && <p style={{ color: 'red' }}>{errors.experienceYears.message}</p>}
        </div>
      </fieldset>

      {/* Previous Jobs */}
      <fieldset>
        <legend>Previous Jobs</legend>

        {fields.map((field, index) => (
          <div key={field.id} style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
            <h4>Job {index + 1}</h4>

            <div>
              <input {...register(`previousJobs.${index}.company`)} placeholder="Company" />
              {errors.previousJobs?.[index]?.company && (
                <p style={{ color: 'red' }}>{errors.previousJobs[index].company.message}</p>
              )}
            </div>

            <div>
              <input {...register(`previousJobs.${index}.position`)} placeholder="Position" />
              {errors.previousJobs?.[index]?.position && (
                <p style={{ color: 'red' }}>{errors.previousJobs[index].position.message}</p>
              )}
            </div>

            <div>
              <input type="date" {...register(`previousJobs.${index}.startDate`)} />
              {errors.previousJobs?.[index]?.startDate && (
                <p style={{ color: 'red' }}>{errors.previousJobs[index].startDate.message}</p>
              )}
            </div>

            <div>
              <input type="date" {...register(`previousJobs.${index}.endDate`)} />
              {errors.previousJobs?.[index]?.endDate && (
                <p style={{ color: 'red' }}>{errors.previousJobs[index].endDate.message}</p>
              )}
            </div>

            {fields.length > 1 && (
              <button type="button" onClick={() => remove(index)}>Remove Job</button>
            )}
          </div>
        ))}

        {errors.previousJobs?.root && (
          <p style={{ color: 'red' }}>{errors.previousJobs.root.message}</p>
        )}

        <button
          type="button"
          onClick={() => append({ company: '', position: '', startDate: '', endDate: '' })}
        >
          Add Previous Job
        </button>
      </fieldset>

      {/* Skills */}
      <fieldset>
        <legend>Skills</legend>
        <div>
          <input
            {...register("skills")}
            placeholder="Enter skills separated by commas (e.g., JavaScript, React, Node.js)"
          />
          {errors.skills && <p style={{ color: 'red' }}>{errors.skills.message}</p>}
        </div>
      </fieldset>

      <button type="submit">Submit Application</button>
    </form>
  );
}

// Example valid data:
// {
//   name: "John Doe",
//   email: "john@example.com",
//   phone: "1234567890",
//   currentPosition: "Senior Developer",
//   desiredPosition: "Lead Developer",
//   experienceYears: 5,
//   previousJobs: [
//     { company: "Google", position: "Developer", startDate: "2019-01-01", endDate: "2024-01-01" }
//   ],
//   skills: "JavaScript, React, TypeScript, Node.js"
// }
// Result: skills becomes ["JavaScript", "React", "TypeScript", "Node.js"]

⭐⭐⭐⭐ Bài 4: Schema-based Localization (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Build multi-language form validation
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Nhiệm vụ:
 * 1. So sánh ít nhất 3 approaches:
 *    - A: Hard-code error messages cho mỗi language
 *    - B: Dùng i18n library (react-i18next)
 *    - C: Custom error map function với Zod
 * 2. Document pros/cons mỗi approach
 * 3. Chọn approach C (custom error map - no external deps)
 * 4. Viết ADR
 *
 * ADR Template:
 * - Context: Multi-language form cần dynamic error messages
 * - Decision: Dùng Zod setErrorMap với custom translation function
 * - Rationale: Lightweight, no deps, Zod native feature
 * - Consequences: Phải maintain translation object manually
 * - Alternatives Considered: i18next (too heavy), hard-code (not scalable)
 *
 * 💻 PHASE 2: Implementation (30 phút)
 * Build checkout form với English/Vietnamese toggle
 * Fields: name, email, cardNumber, expiryDate, cvv
 *
 * 🧪 PHASE 3: Testing (10 phút)
 * - [ ] Switch language → error messages update
 * - [ ] All validation rules work in both languages
 * - [ ] Type safety maintained
 */

// TODO: Implement ADR và solution
💡 Solution
jsx
import { useState } from 'react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * ADR: Multi-language Form Validation
 *
 * Context:
 * Cần build checkout form support English và Vietnamese.
 * Error messages phải hiển thị đúng ngôn ngữ user chọn.
 *
 * Decision:
 * Dùng Zod's setErrorMap để customize error messages theo language.
 *
 * Rationale:
 * - Zod native feature, không cần external library
 * - Lightweight (no i18next bundle)
 * - Dễ maintain translations trong 1 object
 * - Type-safe
 *
 * Consequences:
 * - Phải maintain translation object manually
 * - Mỗi validation error cần translate
 * - Không có advanced i18n features (pluralization, date formatting)
 *
 * Alternatives Considered:
 * - A: Hard-code messages → không scalable
 * - B: react-i18next → overkill cho simple use case, large bundle
 */

type Language = 'en' | 'vi';

// Translation object
const translations = {
  en: {
    required: 'This field is required',
    invalidEmail: 'Invalid email address',
    invalidCard: 'Invalid card number',
    invalidExpiry: 'Invalid expiry date (MM/YY)',
    invalidCVV: 'CVV must be 3 digits',
    minLength: 'Must be at least {{min}} characters',
  },
  vi: {
    required: 'Trường này là bắt buộc',
    invalidEmail: 'Địa chỉ email không hợp lệ',
    invalidCard: 'Số thẻ không hợp lệ',
    invalidExpiry: 'Ngày hết hạn không hợp lệ (MM/YY)',
    invalidCVV: 'CVV phải là 3 chữ số',
    minLength: 'Phải có ít nhất {{min}} ký tự',
  }
};

// Custom error map factory
const createErrorMap = (lang: Language): z.ZodErrorMap => {
  return (issue, ctx) => {
    const t = translations[lang];

    switch (issue.code) {
      case z.ZodIssueCode.invalid_string:
        if (issue.validation === 'email') {
          return { message: t.invalidEmail };
        }
        break;
      case z.ZodIssueCode.too_small:
        if (issue.type === 'string') {
          return {
            message: t.minLength.replace('{{min}}', String(issue.minimum))
          };
        }
        break;
    }

    return { message: ctx.defaultError };
  };
};

// Schema factory - tạo schema với custom messages theo language
const createCheckoutSchema = (lang: Language) => {
  const t = translations[lang];

  return z.object({
    name: z.string().min(1, t.required).min(2, t.minLength.replace('{{min}}', '2')),
    email: z.string().min(1, t.required).email(t.invalidEmail),
    cardNumber: z.string()
      .min(1, t.required)
      .regex(/^\d{16}$/, t.invalidCard),
    expiryDate: z.string()
      .min(1, t.required)
      .regex(/^(0[1-9]|1[0-2])\/\d{2}$/, t.invalidExpiry),
    cvv: z.string()
      .min(1, t.required)
      .regex(/^\d{3}$/, t.invalidCVV)
  });
};

type CheckoutFormData = z.infer<ReturnType<typeof createCheckoutSchema>>;

/**
 * Checkout form với multi-language support
 */
function MultiLanguageCheckoutForm() {
  const [language, setLanguage] = useState<Language>('en');

  // Recreate schema when language changes
  const schema = createCheckoutSchema(language);

  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<CheckoutFormData>({
    resolver: zodResolver(schema)
  });

  const onSubmit = (data: CheckoutFormData) => {
    console.log('Checkout data:', data);
  };

  const labels = {
    en: {
      title: 'Checkout',
      name: 'Full Name',
      email: 'Email',
      cardNumber: 'Card Number',
      expiryDate: 'Expiry Date (MM/YY)',
      cvv: 'CVV',
      submit: 'Complete Purchase',
      switchLang: 'Switch to Vietnamese'
    },
    vi: {
      title: 'Thanh toán',
      name: 'Họ và tên',
      email: 'Email',
      cardNumber: 'Số thẻ',
      expiryDate: 'Ngày hết hạn (MM/YY)',
      cvv: 'CVV',
      submit: 'Hoàn tất thanh toán',
      switchLang: 'Switch to English'
    }
  };

  const t = labels[language];

  return (
    <div>
      <button
        type="button"
        onClick={() => setLanguage(lang => lang === 'en' ? 'vi' : 'en')}
      >
        {t.switchLang}
      </button>

      <form onSubmit={handleSubmit(onSubmit)}>
        <h2>{t.title}</h2>

        <div>
          <label>{t.name}</label>
          <input {...register("name")} />
          {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
        </div>

        <div>
          <label>{t.email}</label>
          <input {...register("email")} />
          {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
        </div>

        <div>
          <label>{t.cardNumber}</label>
          <input {...register("cardNumber")} placeholder="1234567890123456" />
          {errors.cardNumber && <p style={{ color: 'red' }}>{errors.cardNumber.message}</p>}
        </div>

        <div>
          <label>{t.expiryDate}</label>
          <input {...register("expiryDate")} placeholder="12/25" />
          {errors.expiryDate && <p style={{ color: 'red' }}>{errors.expiryDate.message}</p>}
        </div>

        <div>
          <label>{t.cvv}</label>
          <input {...register("cvv")} placeholder="123" maxLength={3} />
          {errors.cvv && <p style={{ color: 'red' }}>{errors.cvv.message}</p>}
        </div>

        <button type="submit">{t.submit}</button>
      </form>
    </div>
  );
}

// Usage example:
// Click "Switch to Vietnamese" → All error messages display in Vietnamese
// Validation logic stays the same, only messages change

⭐⭐⭐⭐⭐ Bài 5: Production-grade E-commerce Checkout (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Build production-ready checkout flow
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * Multi-step checkout: Shipping → Payment → Review
 * - Step 1: Shipping info (name, address, phone)
 * - Step 2: Payment (card number, expiry, cvv, billing address)
 * - Step 3: Review & confirm
 *
 * 🏗️ Technical Design Doc:
 * 1. Component Architecture
 *    - CheckoutWizard (container)
 *    - ShippingStep, PaymentStep, ReviewStep (presentational)
 *    - useCheckoutForm (custom hook)
 *
 * 2. State Management Strategy
 *    - useState cho current step
 *    - React Hook Form cho form state
 *    - Context KHÔNG cần (data flow đơn giản)
 *
 * 3. Validation Strategy
 *    - Validate per step (partial validation)
 *    - Final validation on submit
 *    - Async validation cho billing address (simulate API call)
 *
 * 4. Performance Considerations
 *    - Memoize schemas (useMemo)
 *    - Debounce async validation
 *    - Code splitting per step (comment for future upgrade)
 *
 * 5. Error Handling Strategy
 *    - Field-level errors
 *    - Step-level errors
 *    - Submission errors
 *
 * ✅ Production Checklist:
 * - [ ] Type-safe với TypeScript
 * - [ ] Error handling đầy đủ
 * - [ ] Loading states cho async validation
 * - [ ] Empty states
 * - [ ] Validation messages clear
 * - [ ] Accessibility (ARIA labels, keyboard nav)
 * - [ ] Mobile responsive (comment about CSS)
 * - [ ] Data persistence (comment for localStorage upgrade)
 *
 * 📝 Documentation:
 * - Schema structure
 * - Component hierarchy
 * - Usage examples
 */

// TODO: Full implementation với production-grade quality
💡 Solution
jsx
import { useState, useMemo } from 'react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

/**
 * PRODUCTION-GRADE E-COMMERCE CHECKOUT
 *
 * Architecture:
 * - Multi-step wizard (Shipping → Payment → Review)
 * - Schema validation per step
 * - Type-safe data flow
 * - Async validation simulation
 *
 * Future upgrades:
 * - Add localStorage persistence (Ngày 24: useLocalStorage)
 * - Add API integration (Phase 6: Backend integration)
 * - Add code splitting (React.lazy - chưa học)
 */

// ============================================
// SCHEMAS
// ============================================

const shippingSchema = z.object({
  fullName: z.string().min(2, "Full name must be at least 2 characters"),
  phone: z.string().regex(/^\d{10}$/, "Phone must be 10 digits"),
  address: z.string().min(10, "Address must be at least 10 characters"),
  city: z.string().min(1, "City is required"),
  zipCode: z.string().regex(/^\d{5}$/, "ZIP code must be 5 digits"),
  country: z.string().min(1, "Country is required")
});

const paymentSchema = z.object({
  cardNumber: z.string().regex(/^\d{16}$/, "Card number must be 16 digits"),
  cardHolder: z.string().min(2, "Card holder name required"),
  expiryDate: z.string().regex(/^(0[1-9]|1[0-2])\/\d{2}$/, "Format: MM/YY"),
  cvv: z.string().regex(/^\d{3}$/, "CVV must be 3 digits"),
  billingAddressSame: z.boolean(),
  billingAddress: z.string().optional()
}).refine(
  data => {
    if (!data.billingAddressSame) {
      return data.billingAddress && data.billingAddress.length >= 10;
    }
    return true;
  },
  {
    message: "Billing address must be at least 10 characters",
    path: ["billingAddress"]
  }
);

// Combined schema for final submission
const checkoutSchema = z.object({
  shipping: shippingSchema,
  payment: paymentSchema
});

type ShippingData = z.infer<typeof shippingSchema>;
type PaymentData = z.infer<typeof paymentSchema>;
type CheckoutData = z.infer<typeof checkoutSchema>;

// ============================================
// STEP 1: SHIPPING
// ============================================

/**
 * Shipping information step
 */
function ShippingStep({ onNext, defaultValues }: {
  onNext: (data: ShippingData) => void;
  defaultValues?: Partial<ShippingData>;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<ShippingData>({
    resolver: zodResolver(shippingSchema),
    defaultValues
  });

  return (
    <form onSubmit={handleSubmit(onNext)}>
      <h2>Shipping Information</h2>

      <div>
        <label htmlFor="fullName">Full Name *</label>
        <input id="fullName" {...register("fullName")} />
        {errors.fullName && <p style={{ color: 'red' }}>{errors.fullName.message}</p>}
      </div>

      <div>
        <label htmlFor="phone">Phone *</label>
        <input id="phone" {...register("phone")} placeholder="1234567890" />
        {errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
      </div>

      <div>
        <label htmlFor="address">Address *</label>
        <input id="address" {...register("address")} />
        {errors.address && <p style={{ color: 'red' }}>{errors.address.message}</p>}
      </div>

      <div>
        <label htmlFor="city">City *</label>
        <input id="city" {...register("city")} />
        {errors.city && <p style={{ color: 'red' }}>{errors.city.message}</p>}
      </div>

      <div>
        <label htmlFor="zipCode">ZIP Code *</label>
        <input id="zipCode" {...register("zipCode")} placeholder="12345" />
        {errors.zipCode && <p style={{ color: 'red' }}>{errors.zipCode.message}</p>}
      </div>

      <div>
        <label htmlFor="country">Country *</label>
        <select id="country" {...register("country")}>
          <option value="">Select country</option>
          <option value="US">United States</option>
          <option value="VN">Vietnam</option>
        </select>
        {errors.country && <p style={{ color: 'red' }}>{errors.country.message}</p>}
      </div>

      <button type="submit">Continue to Payment</button>
    </form>
  );
}

// ============================================
// STEP 2: PAYMENT
// ============================================

/**
 * Payment information step
 */
function PaymentStep({ onNext, onBack, defaultValues }: {
  onNext: (data: PaymentData) => void;
  onBack: () => void;
  defaultValues?: Partial<PaymentData>;
}) {
  const {
    register,
    watch,
    handleSubmit,
    formState: { errors }
  } = useForm<PaymentData>({
    resolver: zodResolver(paymentSchema),
    defaultValues: defaultValues || { billingAddressSame: true }
  });

  const billingAddressSame = watch("billingAddressSame");

  return (
    <form onSubmit={handleSubmit(onNext)}>
      <h2>Payment Information</h2>

      <div>
        <label htmlFor="cardNumber">Card Number *</label>
        <input id="cardNumber" {...register("cardNumber")} placeholder="1234567890123456" />
        {errors.cardNumber && <p style={{ color: 'red' }}>{errors.cardNumber.message}</p>}
      </div>

      <div>
        <label htmlFor="cardHolder">Card Holder *</label>
        <input id="cardHolder" {...register("cardHolder")} />
        {errors.cardHolder && <p style={{ color: 'red' }}>{errors.cardHolder.message}</p>}
      </div>

      <div>
        <label htmlFor="expiryDate">Expiry Date *</label>
        <input id="expiryDate" {...register("expiryDate")} placeholder="12/25" />
        {errors.expiryDate && <p style={{ color: 'red' }}>{errors.expiryDate.message}</p>}
      </div>

      <div>
        <label htmlFor="cvv">CVV *</label>
        <input id="cvv" {...register("cvv")} placeholder="123" maxLength={3} />
        {errors.cvv && <p style={{ color: 'red' }}>{errors.cvv.message}</p>}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register("billingAddressSame")} />
          Billing address same as shipping
        </label>
      </div>

      {!billingAddressSame && (
        <div>
          <label htmlFor="billingAddress">Billing Address *</label>
          <input id="billingAddress" {...register("billingAddress")} />
          {errors.billingAddress && <p style={{ color: 'red' }}>{errors.billingAddress.message}</p>}
        </div>
      )}

      <div>
        <button type="button" onClick={onBack}>Back to Shipping</button>
        <button type="submit">Review Order</button>
      </div>
    </form>
  );
}

// ============================================
// STEP 3: REVIEW
// ============================================

/**
 * Review and confirm step
 */
function ReviewStep({ data, onBack, onSubmit }: {
  data: CheckoutData;
  onBack: () => void;
  onSubmit: () => void;
}) {
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleConfirm = async () => {
    setIsSubmitting(true);

    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1500));

    setIsSubmitting(false);
    onSubmit();
  };

  return (
    <div>
      <h2>Review Your Order</h2>

      <section>
        <h3>Shipping Information</h3>
        <p><strong>Name:</strong> {data.shipping.fullName}</p>
        <p><strong>Phone:</strong> {data.shipping.phone}</p>
        <p><strong>Address:</strong> {data.shipping.address}</p>
        <p><strong>City:</strong> {data.shipping.city}</p>
        <p><strong>ZIP:</strong> {data.shipping.zipCode}</p>
        <p><strong>Country:</strong> {data.shipping.country}</p>
      </section>

      <section>
        <h3>Payment Information</h3>
        <p><strong>Card:</strong> **** **** **** {data.payment.cardNumber.slice(-4)}</p>
        <p><strong>Holder:</strong> {data.payment.cardHolder}</p>
        <p><strong>Expiry:</strong> {data.payment.expiryDate}</p>
        <p>
          <strong>Billing Address:</strong>{' '}
          {data.payment.billingAddressSame
            ? 'Same as shipping'
            : data.payment.billingAddress
          }
        </p>
      </section>

      <div>
        <button type="button" onClick={onBack} disabled={isSubmitting}>
          Back to Payment
        </button>
        <button onClick={handleConfirm} disabled={isSubmitting}>
          {isSubmitting ? 'Processing...' : 'Confirm Order'}
        </button>
      </div>
    </div>
  );
}

// ============================================
// MAIN WIZARD
// ============================================

/**
 * Main checkout wizard component
 * Manages multi-step flow and data aggregation
 */
function CheckoutWizard() {
  const [step, setStep] = useState<1 | 2 | 3>(1);
  const [shippingData, setShippingData] = useState<ShippingData | null>(null);
  const [paymentData, setPaymentData] = useState<PaymentData | null>(null);

  // Memoize combined data
  const checkoutData = useMemo<CheckoutData | null>(() => {
    if (shippingData && paymentData) {
      return { shipping: shippingData, payment: paymentData };
    }
    return null;
  }, [shippingData, paymentData]);

  const handleShippingSubmit = (data: ShippingData) => {
    setShippingData(data);
    setStep(2);
  };

  const handlePaymentSubmit = (data: PaymentData) => {
    setPaymentData(data);
    setStep(3);
  };

  const handleFinalSubmit = () => {
    console.log('Order confirmed!', checkoutData);
    alert('Order placed successfully!');

    // Reset wizard
    setStep(1);
    setShippingData(null);
    setPaymentData(null);
  };

  return (
    <div>
      {/* Progress indicator */}
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
        <span style={{ fontWeight: step >= 1 ? 'bold' : 'normal' }}>1. Shipping</span>
        <span style={{ fontWeight: step >= 2 ? 'bold' : 'normal' }}>2. Payment</span>
        <span style={{ fontWeight: step >= 3 ? 'bold' : 'normal' }}>3. Review</span>
      </div>

      {/* Step content */}
      {step === 1 && (
        <ShippingStep
          onNext={handleShippingSubmit}
          defaultValues={shippingData || undefined}
        />
      )}

      {step === 2 && (
        <PaymentStep
          onNext={handlePaymentSubmit}
          onBack={() => setStep(1)}
          defaultValues={paymentData || undefined}
        />
      )}

      {step === 3 && checkoutData && (
        <ReviewStep
          data={checkoutData}
          onBack={() => setStep(2)}
          onSubmit={handleFinalSubmit}
        />
      )}
    </div>
  );
}

// Example flow:
// 1. Fill shipping → data saved, move to step 2
// 2. Fill payment → data saved, move to step 3
// 3. Review → submit → success
// Can go back to edit previous steps without losing data

📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)

Bảng So Sánh: Inline vs Schema Validation

AspectInline ValidationSchema Validation (Zod)
Code Organization❌ Scattered across components✅ Centralized in schema files
Type Safety❌ Manual TypeScript types✅ Auto-inferred from schema
Reusability❌ Duplicate validation logic✅ Share schemas across forms
Maintainability❌ Hard to update rules✅ Change once, apply everywhere
Error Messages❌ Strings scattered in JSX✅ Centralized, easy to localize
Testing❌ Must test through UI✅ Can test schemas in isolation
Bundle Size✅ No extra library⚠️ +8KB (Zod)
Learning Curve✅ Simple, straightforward⚠️ Need to learn Zod API
Complex Validation❌ Hard to express dependencies✅ Refine, transform, compose
Runtime Validation❌ Only on form submit✅ Can validate anywhere (API, etc)

Decision Tree: Khi nào dùng Schema Validation?

┌─────────────────────────────────────┐
│ Câu hỏi 1: Form có >3 fields?       │
└─────────────────────────────────────┘

         ├─ NO ──► Inline validation OK

         └─ YES ──► Tiếp tục

      ┌─────────────────────────────────────┐
      │ Câu hỏi 2: Validation logic phức    │
      │ tạp (cross-field, conditional)?     │
      └─────────────────────────────────────┘

                    ├─ NO ──► Cân nhắc inline hoặc schema

                    └─ YES ──► Dùng Schema

         ┌─────────────────────────────────────┐
         │ Câu hỏi 3: Schema được reuse ở      │
         │ nhiều nơi (API, multiple forms)?    │
         └─────────────────────────────────────┘

                               ├─ NO ──► Schema vẫn tốt (maintainability)

                               └─ YES ──► DEFINITELY Schema

When to Use Each Approach

✅ Dùng Inline Validation khi:

  • Simple form (1-3 fields)
  • One-off form không reuse
  • Prototype/POC nhanh
  • Team không muốn thêm dependencies

✅ Dùng Schema Validation (Zod) khi:

  • Production application
  • Complex validation rules
  • Multiple forms chia sẻ validation logic
  • Cần type safety mạnh
  • Team sẵn sàng học thêm tool

✅ Dùng Zod + Alternatives:

  • Yup: Nếu team đã dùng Yup (legacy project)
    • Pros: API tương tự Zod, mature ecosystem
    • Cons: Ít type-safe hơn, bundle lớn hơn
  • Joi: Backend validation (Node.js)
  • Vest: Nếu muốn validation library độc lập với form library

🧪 PHẦN 5: DEBUG LAB (20 phút)

Bug 1: Schema Không Validate ❌

jsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';

// ❌ BUG: Form submit luôn pass, không validate
const schema = z.object({
  email: z.string().email('Invalid email'),
});

function BuggyForm() {
  const { register, handleSubmit } = useForm({
    // resolver: zodResolver(schema) // Forgot this line!
  });

  const onSubmit = (data) => {
    console.log(data); // Logs even with invalid email!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      <button type='submit'>Submit</button>
    </form>
  );
}

🔍 Debug Questions:

  1. Tại sao form vẫn submit với email invalid?
  2. Làm sao kiểm tra Zod có được apply không?
  3. Fix như thế nào?

💡 Solution:

Xem giải thích

Vấn đề: Forgot to pass zodResolver(schema) to useForm's resolver option. React Hook Form không biết phải validate với Zod schema.

Fix:

jsx
import { zodResolver } from '@hookform/resolvers/zod';

const { register, handleSubmit } = useForm({
  resolver: zodResolver(schema), // ✅ Add this!
});

Cách kiểm tra:

jsx
const {
  formState: { errors },
} = useForm({
  resolver: zodResolver(schema),
});

console.log(errors); // Should show errors if validation fails

Prevention:

  • Luôn import zodResolver cùng với zuseForm
  • Check formState.errors trong DevTools
  • Unit test schema: schema.safeParse({ email: "invalid" })

Bug 2: Optional Field Validation Sai ❌

jsx
// ❌ BUG: Phone validation fail ngay cả khi empty
const schema = z.object({
  phone: z
    .string()
    .regex(/^\d{10}$/, 'Phone must be 10 digits')
    .optional(),
});

function PhoneForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input
        {...register('phone')}
        placeholder='Optional phone'
      />
      {errors.phone && <p>{errors.phone.message}</p>}
      <button type='submit'>Submit</button>
    </form>
  );
}

// Submit với empty phone → ERROR: "Phone must be 10 digits"
// Expected: Should allow empty

🔍 Debug Questions:

  1. Tại sao optional field vẫn validate khi empty?
  2. optional() hoạt động thế nào với regex()?
  3. Fix thế nào để allow empty OR valid format?

💡 Solution:

Xem giải thích

Vấn đề: Khi input empty, value là "" (empty string), không phải undefined.
.optional() chỉ skip validation khi value là undefined, không phải empty string.

Fix Option 1: Allow empty string

jsx
const schema = z.object({
  phone: z
    .string()
    .regex(/^\d{10}$/, 'Phone must be 10 digits')
    .optional()
    .or(z.literal('')), // ✅ Allow empty string
});

Fix Option 2: Transform empty to undefined

jsx
const schema = z.object({
  phone: z
    .string()
    .transform((val) => (val === '' ? undefined : val))
    .optional()
    .refine((val) => val === undefined || /^\d{10}$/.test(val), {
      message: 'Phone must be 10 digits',
    }),
});

Fix Option 3: Use union type (cleanest)

jsx
const schema = z.object({
  phone: z.union([z.string().regex(/^\d{10}$/), z.literal('')]).optional(),
});

Prevention:

  • Remember: HTML inputs default to "", not undefined
  • Always test empty state: schema.safeParse({ phone: "" })
  • Use .or(z.literal("")) pattern for optional fields

Bug 3: Cross-field Validation Path Sai ❌

jsx
// ❌ BUG: Password mismatch error hiện sai chỗ
const schema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine(
    (data) => data.password === data.confirmPassword,
    { message: "Passwords don't match" }, // Missing path!
  );

function PasswordForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input
        type='password'
        {...register('password')}
      />
      {errors.password && <p>{errors.password.message}</p>}

      <input
        type='password'
        {...register('confirmPassword')}
      />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      {/* ❌ Error hiện ở đây thay vì confirmPassword field */}
      {errors.root && <p>{errors.root.message}</p>}

      <button type='submit'>Submit</button>
    </form>
  );
}

🔍 Debug Questions:

  1. Error "Passwords don't match" hiện ở đâu?
  2. Làm sao chỉ định error hiện ở confirmPassword field?
  3. Tại sao cần path trong refine options?

💡 Solution:

Xem giải thích

Vấn đề: Khi .refine() không có path, error được gán vào errors.root (form-level error), không phải field-specific error.

Fix:

jsx
const schema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ['confirmPassword'], // ✅ Specify error path!
  });

// Now error shows under confirmPassword field:
{
  errors.confirmPassword && <p>{errors.confirmPassword.message}</p>;
}

How path works:

  • path: ["confirmPassword"]errors.confirmPassword.message
  • path: ["password"]errors.password.message
  • No path → errors.root.message

Multiple paths (advanced):

jsx
.refine(data => ..., {
  message: "Error message",
  path: ["field1", "field2"] // Error on both fields
})

Prevention:

  • Always specify path in cross-field validation
  • Match path với field where error should display
  • Test: Submit invalid data, check where error appears

✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)

Knowledge Check

  • [ ] Tôi hiểu sự khác biệt giữa inline validation và schema-based validation
  • [ ] Tôi biết cách define Zod schema cho primitive types (string, number, boolean)
  • [ ] Tôi biết cách validate email, regex patterns với Zod
  • [ ] Tôi biết cách làm nested objects và arrays trong schema
  • [ ] Tôi biết cách integrate Zod với React Hook Form qua zodResolver
  • [ ] Tôi biết cách infer TypeScript types từ schema với z.infer
  • [ ] Tôi biết cách custom error messages trong Zod
  • [ ] Tôi biết cách dùng .refine() cho cross-field validation
  • [ ] Tôi biết cách handle optional fields correctly (.optional().or(z.literal("")))
  • [ ] Tôi biết cách transform data với .transform()
  • [ ] Tôi biết cách compose schemas với .extend()
  • [ ] Tôi biết khi nào nên dùng schema validation vs inline validation

Code Review Checklist

Khi review code có Zod schemas, check:

Schema Design:

  • [ ] Schema có clear, descriptive names
  • [ ] Validation rules match business requirements
  • [ ] Error messages user-friendly và actionable
  • [ ] Optional fields handle empty strings correctly
  • [ ] No duplicate validation logic

Integration:

  • [ ] zodResolver được pass vào useForm
  • [ ] TypeScript types được infer từ schema (z.infer)
  • [ ] Không manual type form data nếu có schema
  • [ ] Cross-field validation có path chỉ đúng field

Error Handling:

  • [ ] Mọi field đều show errors khi invalid
  • [ ] Error messages hiển thị đúng chỗ
  • [ ] Loading states cho async validation (nếu có)
  • [ ] Form không submit khi có errors

Performance:

  • [ ] Schemas được define ngoài component (không recreate mỗi render)
  • [ ] useMemo cho complex schema computations
  • [ ] Không validate unnecessarily (debounce nếu cần)

Maintainability:

  • [ ] Base schemas được reuse
  • [ ] Validation logic tách ra khỏi UI code
  • [ ] Comments giải thích complex validation rules
  • [ ] Schemas có thể test independently

🏠 BÀI TẬP VỀ NHÀ

Bắt buộc (30 phút)

Task: Refactor một form hiện có sang Zod

Chọn 1 form từ project cũ (hoặc từ bài tập Ngày 41-42) và refactor:

  1. Extract validation logic thành Zod schema
  2. Replace inline validation với zodResolver
  3. Add TypeScript types với z.infer
  4. Improve error messages
  5. Document: Before/After comparison

Nâng cao (60 phút)

Task: Build Event Registration Form

Requirements:

  • Event details: name, date, location, capacity (number)
  • Attendee info: name, email, phone, dietary restrictions (optional)
  • Ticket selection: ticketType (enum: "vip" | "regular" | "student"), quantity
  • Conditional validation:
    • VIP tickets: require company name
    • Student tickets: require student ID
    • Total attendees (quantity) không vượt quá capacity
  • Payment: amount auto-calculated based on ticket type
  • Multi-language error messages (English + Vietnamese)

Advanced features:

  • Schema composition (base attendee schema)
  • Custom validation (capacity check)
  • Transformations (calculate total price)
  • Enums (ticket types)
  • Conditional fields based on ticket type

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. Zod Official Documentation

  2. React Hook Form - Zod Integration

Đọc thêm

  1. Zod vs Yup Comparison

  2. TypeScript-first Schema Validation

  3. Advanced Zod Patterns

    • Discriminated unions
    • Recursive schemas
    • Custom error maps

🔗 KẾT NỐI KIẾN THỨC

Kiến thức nền

  • Ngày 41: React Hook Form basics - useForm, register, handleSubmit
  • Ngày 42: React Hook Form advanced - field arrays, watch, custom validation
  • Ngày 13: Forms với useState (để so sánh complexity)
  • Ngày 1-2: ES6+ (array methods, destructuring cho schema composition)

Hướng tới

  • Ngày 44: Multi-step forms - sẽ dùng Zod cho validation từng step
  • Ngày 45: Project 6 - Registration flow với complex Zod schemas
  • Phase 6 (Testing): Test Zod schemas independently
  • Phase 6 (TypeScript): Advanced type inference từ schemas

💡 SENIOR INSIGHTS

Cân Nhắc Production

1. Bundle Size Optimization

jsx
// ❌ Import toàn bộ Zod
import { z } from 'zod';

// ✅ Tree-shakeable imports (nếu bundler support)
import { object, string, number } from 'zod';

// Note: Zod đã optimized tốt, không cần quá lo

2. Schema Versioning

jsx
// Khi API schema thay đổi, version schemas
const userSchemaV1 = z.object({ name: z.string() });
const userSchemaV2 = z.object({
  name: z.string(),
  email: z.string().email()
});

// Migration function
function migrateUser(data: unknown, version: 1 | 2) {
  if (version === 1) {
    return userSchemaV2.parse({
      ...userSchemaV1.parse(data),
      email: ''
    });
  }
  return userSchemaV2.parse(data);
}

3. Async Validation với Debounce

jsx
// Future upgrade: Combine với async validation
const usernameSchema = z.string().min(3);
// Note: Zod không support async validation trực tiếp
// Cần dùng RHF's validate option cho async checks

// Example pattern (for future reference):
register('username', {
  validate: async (value) => {
    const result = await checkUsernameAvailable(value);
    return result.available || 'Username taken';
  },
});

Câu Hỏi Phỏng Vấn

Junior Level:

  1. Q: Zod là gì? Tại sao dùng Zod thay vì inline validation? A: Schema validation library, type-safe, reusable, maintainable

  2. Q: Làm sao integrate Zod với React Hook Form? A: Dùng zodResolver từ @hookform/resolvers/zod

Mid Level: 3. Q: Explain .refine() và khi nào dùng? A: Custom validation logic, cross-field validation, complex business rules

  1. Q: Làm sao handle optional fields có validation conditional? A: .optional().or(z.literal("")) hoặc .transform() + .refine()

Senior Level: 5. Q: So sánh Zod vs Yup, khi nào chọn cái nào? A: Zod: TypeScript-first, better inference, smaller bundle. Yup: mature ecosystem, legacy support

  1. Q: Architect schema structure cho large application với 50+ forms? A:
    • Base schemas trong /schemas/base/
    • Domain-specific schemas trong /schemas/domains/
    • Shared validation rules trong /schemas/rules/
    • Type exports từ schemas
    • Schema composition patterns

War Stories

Story 1: The Great Refactor "Team có 30 forms với inline validation duplicate. Mất 2 tuần refactor sang Zod schemas. Kết quả: giảm 40% validation code, zero regression bugs vì tests schemas riêng. Thêm feature mới giờ chỉ mất 1/3 thời gian."

Story 2: i18n Nightmare "Hardcode English error messages khắp nơi. Khi cần support Vietnamese, phải tìm và replace hàng trăm strings. Học được: dùng Zod error maps + translation keys từ đầu. Migration đau đớn nhưng worth it."

Story 3: Type Safety Saved Production "Backend thay đổi API response structure, frontend crash. Nếu dùng Zod parse API responses, TypeScript báo lỗi compile-time. Lesson: validate data ở mọi boundaries (forms, API, localStorage)."


🎉 Chúc mừng! Bạn đã hoàn thành Ngày 43. Ngày mai chúng ta sẽ học Multi-step Forms - áp dụng tất cả knowledge về RHF và Zod vào wizard pattern phức tạp!

Personal tech knowledge base