Skip to content

บทที่ 3 — Form + Validation

← บทที่ 2 | สารบัญ | บทที่ 4: Data Fetching →

🟡 ระดับ: กลาง — ควรผ่านบท 0–2 (โดยเฉพาะ useState/hook) ให้คล่องก่อน

⚠️ บทนี้ใช้ 2 library ใหม่พร้อมกัน (RHF + Zod) — เป็นเรื่องปกติที่จะรู้สึกหนัก ค่อย ๆ อ่านได้

📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้ตลอด):

  • form = ฟอร์มกรอกข้อมูล (<form> ใน HTML)
  • validation = การตรวจสอบความถูกต้องของข้อมูลที่กรอก (เช่น email ต้องมี @, password ต้อง ≥ 8 ตัว)
  • controlled component = React คุมค่า input ทุกตัวอักษร — มี state เก็บค่า, ทุกการกดคีย์ → setState → re-render
  • uncontrolled component = ปล่อยให้ DOM คุมค่าเอง — React อ่านค่าจาก DOM ตอน submit (เร็วกว่า แต่ควบคุมยากกว่า)
  • React Hook Form (RHF) = library ที่ทำให้เขียน form ใหญ่ๆ ง่าย ใช้ uncontrolled ภายในเพื่อความเร็ว
  • Zod (อ่าน "ซอด") = library สร้าง schema (= โครงสร้างที่บอกว่าข้อมูลต้องหน้าตาเป็นยังไง) แล้วใช้ validate ค่าจริง
  • schema = "แบบแปลน" ของข้อมูล — บอกว่าต้องมี field อะไรบ้าง, type อะไร, มีเงื่อนไขอะไร
  • register (ของ RHF) = "ลงทะเบียน" input กับ library → library คืน props (onChange, ref) มาให้กระจายเข้า <input>
  • Controller (ตัว C ใหญ่ ของ RHF) = component ที่ใช้ห่อ UI component จาก library อื่น (เช่น MUI, Ant Design) ให้ทำงานกับ RHF ได้ — ใช้แทน register สำหรับ component ที่ไม่ใช่ native <input> ของ HTML ต่างจาก "controlled" คนละเรื่องกัน
  • headless library = library ที่ให้แค่ logic ไม่บังคับหน้าตา UI (เราเลือก UI เอง)

ทุกแอป — sign up (สมัครสมาชิก), login (เข้าระบบ), search (ค้นหา), comment (แสดงความเห็น) — มี form

หลังจบบท คุณจะ:

  • เข้าใจ controlled (React คุมค่า input) vs uncontrolled (ปล่อยให้ DOM คุมค่าเอง) component
  • ใช้ React Hook Form + Zod (stack มาตรฐานปี 2026)
  • จัดการ validation, error message, submit state
  • ทำ form ซับซ้อน (multi-step, dynamic fields)

1. Controlled Component — React คุม value

tsx
function NameForm() {
    const [name, setName] = useState('');
    
    return (
        <input
            value={name}                                  // ✅ value มาจาก state
            onChange={e => setName(e.target.value)}        // ✅ update state
        />
    );
}

React คุม value ทุกอย่างDOM แค่แสดง state

ข้อดี:

  • validate ขณะพิมพ์ได้
  • transform value (uppercase, format) ได้
  • เรียกค่า โดยไม่ต้อง query DOM

ข้อเสีย:

  • re-render ทุก keystroke (ปัญหาเมื่อ form ใหญ่)

2. Uncontrolled Component — DOM คุม value

tsx
function NameForm() {
    const nameRef = useRef<HTMLInputElement>(null); // <HTMLInputElement> บอก TypeScript ว่า ref นี้จะชี้ไปที่ input element
    
    const handleSubmit = (e: React.FormEvent) => { // React.FormEvent = type ของ event ที่เกิดจาก form submit
        e.preventDefault();
        console.log(nameRef.current?.value);
    };
    
    return (
        <form onSubmit={handleSubmit}>
            <input ref={nameRef} defaultValue="" />
            <button>Submit</button>
        </form>
    );
}

DOM เก็บ value — React อ่านตอน submit เท่านั้น

ใช้ใน form ใหญ่ที่ controlled ช้า — แต่ปกติใช้ React Hook Form ที่จัดการให้


3. Form แบบเปล่า — ทำมือ

ก่อนใช้ library มาดูการทำ form ด้วยมือล้วน ๆ ก่อน — จัดการ state ของแต่ละ field, errors และ submitting เองด้วย useState วิธีนี้ใช้ได้จริง แต่โค้ดจะยืดยาวซ้ำซาก (verbose — เขียนเยอะแต่ทำน้อย) และยิ่ง field เยอะก็ยิ่งซ้ำซากมากขึ้น นี่คือเหตุผลที่ section ถัด ๆ ไปเราหันไปใช้ form library:

tsx
function SignupForm() {
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [errors, setErrors] = useState<Record<string, string>>({}); // Record<string, string> = object ที่ทั้ง key และ value เป็น string เช่น { name: 'ต้องกรอก', email: 'ไม่ถูกต้อง' }
    const [submitting, setSubmitting] = useState(false);
    
    const validate = (): boolean => {
        const errs: Record<string, string> = {};
        if (!name.trim()) errs.name = "Name required";          // error: กรณีไม่กรอกชื่อ
        if (!email.includes('@')) errs.email = "Invalid email"; // error: email ผิดรูปแบบ — ตัวอย่างง่ายมาก ใช้จริงควรใช้ Zod z.email() หรือ regex ที่เหมาะสม
        if (password.length < 8) errs.password = "Min 8 characters"; // error: รหัสผ่านสั้นเกิน
        setErrors(errs);
        return Object.keys(errs).length === 0;
    };
    
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        if (!validate()) return;
        
        setSubmitting(true);
        try {
            await fetch('/api/signup', { // '/api/signup' = URL ของ backend ที่ต้องมีอยู่แล้ว — ในที่นี้เป็น placeholder ใช้แทน API จริง
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name, email, password }), // ⚠️ ควรใช้ HTTPS เสมอ — password ไม่ควร hash ฝั่ง client (ทำที่ server)
                // 💡 ใส่ autoComplete="new-password" ที่ <input type="password"> ของฟอร์ม signup — บอก browser/password manager ว่านี่คือรหัสผ่านใหม่ ไม่ใช่รหัสผ่านเดิมที่จะกรอกอัตโนมัติ
            });
            // success
        } catch (err) {
            // ...
        } finally {
            setSubmitting(false);
        }
    };
    
    return (
        <form onSubmit={handleSubmit}>
            <div>
                <input
                    placeholder="Name"
                    value={name}
                    onChange={e => setName(e.target.value)}
                />
                {errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
            </div>
            
            <div>
                <input
                    type="email"
                    placeholder="Email"
                    value={email}
                    onChange={e => setEmail(e.target.value)}
                />
                {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
            </div>
            
            <div>
                <input
                    type="password"
                    placeholder="Password"
                    value={password}
                    onChange={e => setPassword(e.target.value)}
                />
                {errors.password && <span style={{ color: 'red' }}>{errors.password}</span>}
            </div>
            
            <button type="submit" disabled={submitting}>
                {submitting ? 'Submitting...' : 'Sign Up'}
            </button>
        </form>
    );
}

ทำได้ — แต่ form 10 fields → state 10 ตัว + validate ลำบาก → ใช้ library


4. React Hook Form + Zod — stack มาตรฐานปี 2026

4.1 ติดตั้ง

bash
npm install react-hook-form zod @hookform/resolvers

บทนี้ใช้ Zod v4 (stable มีนาคม 2025) เป็นหลัก — ของใหม่ใน v4 ที่ตัวอย่างในบทใช้:

  • top-level format helpers: z.email(), z.url(), z.uuid(), z.iso.date(), z.iso.datetime() เป็น shorthand สั้นกว่าที่เพิ่มใน v4 — z.string().email() ของ v3 ยังใช้ได้ปกติ ไม่ได้ถูก deprecated
  • error format: Zod v4 เพิ่ม utility ใหม่ z.flattenError(error) (แทน method error.flatten() เดิม ได้ผลเหมือนกัน), z.treeifyError(error) (ได้ tree structure), z.prettifyError(error) (ได้ readable string) — error.flatten() ยังใช้ได้ใน v4 ไม่ได้ deprecated
  • .overwrite() เป็น experimental ใน v4 (ยังปรับ API อยู่) — บทนี้ไม่ได้ใช้ในตัวอย่าง ถ้าต้องการ keep input type ยังคง .transform() ได้

📦 ต้องใช้ @hookform/resolvers >= 3.10 สำหรับ Zod v4 เช่น npm install react-hook-form zod@^4 @hookform/resolvers@^3.10

📦 breaking changes (การเปลี่ยนแปลงที่ทำให้โค้ดเดิมพัง ต้องแก้) — ถ้ามาจาก Zod v3 อ่าน migration guide ของ Zod เพิ่ม

4.2 ตัวอย่าง

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

// 1. Schema — define rule
const schema = z.object({
    name: z.string().min(1, "Name required").max(100),
    email: z.email("Invalid email"),                       // v4 idiom — แทน z.string().email() (ถ้ายังไม่ชัดเรื่อง Zod v3/v4 ย้อนไปดู §4.1 ด้านบน)
    password: z.string().min(8, "Min 8 characters"),
    age: z.coerce.number().int().min(0).max(150),
});

type FormValues = z.infer<typeof schema>;    // z.infer<typeof schema> = สร้าง TypeScript type จาก schema อัตโนมัติ — ไม่ต้องเขียน type ซ้ำมือ

function SignupForm() {
    const {
        register,
        handleSubmit,
        formState: { errors, isSubmitting },
    } = useForm<FormValues>({
        resolver: zodResolver(schema),
    });
    
    const onSubmit = async (data: FormValues) => {
        console.log(data);
        await fetch('/api/signup', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data),
        });
    };
    
    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <div>
                <input placeholder="Name" {...register('name')} />
                {errors.name && <span>{errors.name.message}</span>}
            </div>
            
            <div>
                <input type="email" placeholder="Email" {...register('email')} />
                {errors.email && <span>{errors.email.message}</span>}
            </div>
            
            <div>
                <input type="password" placeholder="Password" {...register('password')} />
                {errors.password && <span>{errors.password.message}</span>}
            </div>
            
            <div>
                <input type="number" placeholder="Age" {...register('age')} /> {/* z.coerce.number() ใน schema จัดการแปลง string → number ให้แล้ว ไม่ต้องใส่ valueAsNumber ซ้ำ */}
                {errors.age && <span>{errors.age.message}</span>}
                {/* ⚠️ ถ้าปล่อยช่อง Age ว่างไว้ ค่าที่ได้คือ '' → z.coerce.number() แปลงเป็น NaN ซึ่ง Zod จะขึ้น error แบบดิบ เช่น "Expected number, received nan" ไม่ใช่ข้อความที่อ่านง่าย ถ้าต้องการข้อความที่ดีกว่า ให้ใช้ z.coerce.number({ invalid_type_error: "กรุณากรอกอายุ" }) หรือเพิ่ม .refine ตรวจเอง */}
            </div>
            
            <button type="submit" disabled={isSubmitting}>
                {isSubmitting ? 'Submitting...' : 'Sign Up'}
            </button>
        </form>
    );
}

ดูแล้ว — เปลี่ยนจาก 50 บรรทัด → 30 บรรทัด — สะอาด/อ่านง่ายกว่า

4.3 อธิบาย

register('field')

  • {...register('name')} คือ JSX spread — เหมือนกับการเขียน onChange={...} onBlur={...} ref={...} name={...} ทีละอัน แต่ย่อด้วย spread operator ...
  • spread เป็น { onChange, onBlur, ref, name } ให้ input
  • React Hook Form อ่านค่าจาก DOM โดยตรงเป็นหลัก (เร็วกว่า controlled สำหรับ form ใหญ่) → ไม่ re-render ทุก keystroke
  • ผลลัพธ์: form ใหญ่ ๆ ยังเร็ว

handleSubmit(onSubmit)

  • wrap event handler ของเรา
  • เรียก validation → ถ้า OK → เรียก onSubmit(data)
  • ถ้า fail → set errors

formState.errors

  • object ของ error: { name: { message: "..." } }

formState.isSubmitting

  • true ระหว่าง onSubmit run (รองรับ async)

4.4 Zod — schema validation

ts
import { z } from 'zod';

const userSchema = z.object({
    // strings
    name: z.string().min(1).max(100),
    email: z.email(),                 // v4 — แทน z.string().email()
    url: z.url(),                     // v4 — แทน z.string().url()
    
    // numbers
    price: z.number().positive(),
    
    // boolean
    isActive: z.boolean(),
    
    // arrays
    tags: z.array(z.string()).min(1).max(10),
    
    // enum
    role: z.enum(['user', 'admin']),
    
    // optional / nullable
    bio: z.string().optional(),
    middleName: z.string().nullable(),
    
    // default
    status: z.string().default("active"),
    
    // refine — custom validation
    password: z.string().min(8).refine(
        (val) => /[A-Z]/.test(val) && /[0-9]/.test(val),
        { message: "Must have uppercase + number" }
    ),
    
    // coerce — convert input string → number
    age: z.coerce.number().int().min(0).max(150),    // "25" (from input) → 25
});

// nested object
const orderSchema = z.object({
    customer: z.object({
        name: z.string(),
        email: z.email(),
    }),
    items: z.array(z.object({
        productId: z.number(),
        quantity: z.number().int().positive(),
    })).min(1),
});

// union
const idSchema = z.union([z.string(), z.number()]);

// transform
const dateSchema = z.string().transform(s => new Date(s));

// Type inference!
type Order = z.infer<typeof orderSchema>;

4.5 Refine + cross-field

ts
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'],    // error อยู่ที่ field นี้
    }
);

5. Select, Checkbox, Radio

input ที่ไม่ใช่ text ก็ใช้ register ได้เหมือนกัน — select, checkbox, radio แค่ spread {...register('field')} เข้าไป ส่วน schema ฝั่ง Zod ก็กำหนด type ให้ตรง (boolean สำหรับ checkbox, enum สำหรับ radio) เพื่อ validate ค่าที่เลือก:

tsx
<select {...register('country')}>
    <option value="">--Select--</option>
    <option value="th">Thailand</option>
    <option value="jp">Japan</option>
</select>

<input type="checkbox" {...register('agree')} />

<input type="radio" value="male" {...register('gender')} />
<input type="radio" value="female" {...register('gender')} />

Schema:

ts
z.object({
    country: z.string().min(1, "Select country"),
    agree: z.boolean().refine(v => v, "Must agree"),
    gender: z.enum(['male', 'female']),
})

6. Controller — ใช้กับ component ที่ไม่ใช่ native input

register ทำงานกับ native input ผ่าน ref แต่ component ของ UI library (MUI, Ant Design) มักไม่ expose ref ตรง ๆ — Controller แก้ปัญหานี้โดยเชื่อม value/onChange ของ component เข้ากับ react-hook-form ให้ ทำให้ validate component ที่ไม่ใช่ native ได้:

บาง UI library (MUI, Ant Design, custom) ใช้ ref ไม่ได้ → ใช้ Controller:

tsx
import { Controller } from 'react-hook-form';

<Controller
    name="country"
    control={control}
    render={({ field, fieldState }) => (
        // ตัวอย่างนี้ใช้ native <select> — ถ้าใช้ MUI ให้เปลี่ยนเป็น <Select> จาก @mui/material
        <div>
            <select value={field.value} onChange={field.onChange}>
                <option value="">--Select--</option>
                <option value="th">Thailand</option>
                <option value="jp">Japan</option>
            </select>
            {fieldState.error && <span>{fieldState.error.message}</span>}
        </div>
    )}
/>

control มาจาก useForm():

tsx
const { control, ... } = useForm({...});

7. Default values

กำหนดค่าเริ่มต้นของ form ผ่าน defaultValues ใน useForm — สำคัญโดยเฉพาะตอนทำ edit form (เติมข้อมูลเดิมที่ดึงมา) และช่วยให้ controlled input ไม่ขึ้น warning เรื่อง uncontrolled → controlled:

tsx
const { register, handleSubmit } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
        name: '',
        email: '',
        country: 'th',
        agree: false,
    },
});

ใน edit form — load จาก server:

tsx
// user มาจาก props หรือ useQuery (บท Data Fetching)
// reset มาจาก useForm: const { register, handleSubmit, reset } = useForm<FormValues>({...})
useEffect(() => {
    if (user) {
        reset({
            name: user.name,
            email: user.email,
        });
    }
}, [user, reset]);

reset มาจาก useForm — destructure มาพร้อมกับ register, handleSubmit ฯลฯ


8. Watch field

tsx
// watch มาจาก useForm: const { watch, control, ... } = useForm({...})
const password = watch('password');
const showStrengthMeter = password && password.length > 0;

watch('field') = re-render เมื่อ field นี้เปลี่ยน (ใช้ระวัง — wrap ใน component แล้วจะ re-render ทั้ง component ทุกครั้งที่ field เปลี่ยน เปลืองงาน)

ดีกว่า: ใช้ useWatch — subscribe เฉพาะ field ที่ระบุ ทำให้ไม่ re-render เมื่อ field อื่นเปลี่ยน (ต่างจาก watch('password') ที่ subscribe ทุก change ของ form):

tsx
const password = useWatch({ control, name: 'password' });

9. Dynamic fields (useFieldArray)

ตัวอย่าง: form สั่งของหลายชิ้น

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

const schema = z.object({
    customer: z.string().min(1),
    items: z.array(z.object({
        name: z.string().min(1),
        quantity: z.coerce.number().int().positive(), // z.coerce.number() จัดการ string → number ให้อัตโนมัติ
    })).min(1),
});

function OrderForm() {
    const { register, control, handleSubmit, formState: { errors } } = useForm({
        resolver: zodResolver(schema),
        defaultValues: { customer: '', items: [{ name: '', quantity: 1 }] },
    });
    
    const { fields, append, remove } = useFieldArray({
        control,
        name: 'items',
    });
    
    return (
        <form onSubmit={handleSubmit(data => console.log(data))}>
            <input placeholder="Customer" {...register('customer')} />
            
            {fields.map((field, index) => (
                <div key={field.id}>
                    <input
                        placeholder="Item name"
                        {...register(`items.${index}.name`)}
                    />
                    <input
                        type="number"
                        placeholder="Qty"
                        {...register(`items.${index}.quantity`)}
                    />
                    <button type="button" onClick={() => remove(index)}>×</button>
                </div>
            ))}
            
            <button type="button" onClick={() => append({ name: '', quantity: 1 })}>
                Add Item
            </button>
            
            <button type="submit">Submit</button>
        </form>
    );
}

useFieldArray จัดการ array of objects ได้สบาย — append, remove, swap, move


10. Submit error from server

validation ฝั่ง client ไม่พอ — server อาจปฏิเสธ (เช่น email ซ้ำ) ที่ client ไม่รู้ ใช้ setError แสดง error จาก server ผูกกับ field ที่เกี่ยวข้อง หรือใช้ root สำหรับ error รวมที่ไม่ผูกกับ field ใด:

tsx
// ตัวอย่างนี้ใช้ ApiError จาก API layer ที่จะสอนในบท Data Fetching §10
// ถ้ายังไม่ได้อ่านบทนั้น ให้ดู pattern ด้วย fetch ธรรมดาด้านล่างก่อน

// class ApiError ต้องมี field: class ApiError extends Error {
//   constructor(public status: number, message: string, public field?: string) { super(message) }
// }

const { setError, handleSubmit } = useForm<FormValues>({...});

const onSubmit = async (data: FormValues) => {
    try {
        await api.signup(data); // api.signup = function ที่ห่อ fetch ไว้ (ดูบท Data Fetching §10)
    } catch (err) {
        // สมมุติ api layer โยน ApiError ที่มี field + message
        if (err instanceof ApiError && err.field) {
            // keyof FormValues = ชื่อ field ใดชื่อหนึ่งใน schema เช่น 'email' | 'name' | 'password'
            setError(err.field as keyof FormValues, {
                type: 'server',
                message: err.message,
            });
        } else {
            setError('root', { message: 'Something went wrong' });
        }
    }
};

แสดง root error:

tsx
{errors.root && <p>{errors.root.message}</p>}

11. Multi-step form

form ยาว ๆ มักแบ่งเป็นหลายขั้น (wizard — วิซาร์ด — form หลายขั้นที่นำผู้ใช้ทีละขั้น) เคล็ดลับคือใช้ useForm ตัวเดียวครอบทุกขั้น แล้วใช้ trigger(fields) (กระตุ้น validate) เฉพาะ field ของขั้นนั้นก่อนไปต่อ

ส่วน child component แชร์ form state ผ่าน FormProvider + useFormContext ไม่ต้อง prop drilling (prop drilling = ส่ง prop ผ่านหลาย component ชั้นลงไป แม้ชั้นกลางไม่ได้ใช้ — ยุ่งยากและแก้ยากเมื่อ component ลึกมาก):

tsx
// fullSchema รวม field ของทุก step เข้าด้วยกัน
const fullSchema = z.object({
    name: z.string().min(1),
    email: z.email(),
    address: z.string().min(1),
    city: z.string().min(1),
});

function Wizard() {
    const [step, setStep] = useState(1);
    
    const methods = useForm({
        resolver: zodResolver(fullSchema),
        defaultValues: { name: '', email: '', address: '', city: '' },
    });
    
    const next = async () => {
        const fields = step === 1 
            ? ['name', 'email'] as const
            : ['address', 'city'] as const;
        const valid = await methods.trigger(fields);
        if (valid) setStep(s => s + 1);
    };
    
    const onSubmit = (data: z.infer<typeof fullSchema>) => {
        // submit all data
    };
    
    return (
        <form onSubmit={methods.handleSubmit(onSubmit)}>
            {step === 1 && <Step1 register={methods.register} errors={methods.formState.errors} />}
            {step === 2 && <Step2 register={methods.register} errors={methods.formState.errors} />}
            {step === 3 && <Step3 register={methods.register} errors={methods.formState.errors} />} {/* Step3 มีโครงสร้างเหมือน Step1/Step2 */}
            
            <div>
                {step > 1 && <button type="button" onClick={() => setStep(s => s - 1)}>Back</button>}
                {step < 3 && <button type="button" onClick={next}>Next</button>}
                {step === 3 && <button type="submit">Submit</button>}
            </div>
        </form>
    );
}

trigger(fields) — validate เฉพาะ field ที่ระบุ

หรือใช้ FormProvider + useFormContext ใน child:

tsx
import { FormProvider, useFormContext } from 'react-hook-form';

<FormProvider {...methods}>
    <Step1 />
</FormProvider>

function Step1() {
    const { register, formState: { errors } } = useFormContext();
    // ...
}

12. File upload

file upload ต่างจาก input อื่นเพราะค่าเป็น FileList — validate ด้วย Zod (z.instanceof(FileList) + refine เช็คขนาด/ชนิดไฟล์) แล้วตอน submit ต้องห่อใน FormData ส่งแบบ multipart ไม่ใช่ JSON

multipart (รูปแบบส่งข้อมูลทาง HTTP ที่รองรับไฟล์): JSON ส่งได้แค่ text ธรรมดา แต่ไฟล์เป็น binary data — multipart เป็นรูปแบบ HTTP ที่แบ่งข้อมูลเป็นหลายส่วน (parts) ทำให้ส่ง text + ไฟล์ พร้อมกันได้

⚠️ z.instanceof(FileList) ใช้ได้เฉพาะ browser environment — ถ้าใช้ Next.js หรือ run test ใน Node (เช่น Vitest + jsdom ที่สอนในบท Testing) FileList อาจไม่มี global ให้ ทำให้เจอ error ReferenceError: FileList is not defined ทันทีที่ import schema — ในกรณีนั้นให้ใช้ z.custom<FileList>((val) => typeof FileList !== 'undefined' && val instanceof FileList) แทนทุกจุดที่ใช้ z.instanceof(FileList) ด้านล่าง

tsx
const schema = z.object({
    avatar: z.instanceof(FileList).refine(
        files => files.length > 0,
        "Required"
    ).refine(
        files => files[0].size < 5_000_000,
        "Max 5MB"
    ).refine(
        files => ['image/jpeg', 'image/png'].includes(files[0].type),
        "JPEG or PNG only"
    ),
});

function AvatarForm() {
    const { register, handleSubmit } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });
    
    const onSubmit = async (data: z.infer<typeof schema>) => {
        const formData = new FormData();
        formData.append('file', data.avatar[0]);
        
        await fetch('/api/upload', { method: 'POST', body: formData });
    };
    
    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <input type="file" {...register('avatar')} accept="image/*" />
            <button type="submit">Upload</button>
        </form>
    );
}

13. Best Practice

✅ Single source of truth สำหรับ schema

ts
// schema.ts
export const userSchema = z.object({...});
export type User = z.infer<typeof userSchema>;

// ใช้ทุกที่ — form, API contract, type

✅ Disable submit ตอน submitting + error

  • isDirty = form มีการเปลี่ยนแปลงจาก defaultValues แล้ว (ค่า "เลอะ" จากค่าเริ่มต้น) — ถ้าไม่มีการเปลี่ยน ไม่ต้อง submit
  • isValid = ผ่าน validation ทุก field แล้ว
  • dirtyFields = object ระบุว่า field ไหนถูกเปลี่ยนบ้าง เช่น { name: true }
tsx
<button
    type="submit"
    disabled={isSubmitting || !isDirty || !isValid}
>

✅ Show error เมื่อ touched

RHF เก็บ touched fields (field ที่ user เคยคลิกแล้ว blur ออก = เคยแตะ) ที่ formState.touchedFields (object ของ field names):

tsx
const { formState: { errors, touchedFields } } = useForm({...});

{errors.name && touchedFields.name && <span>{errors.name.message}</span>}

มี dirtyFields ด้วย (field ที่ค่าเปลี่ยนจาก default — "เลอะ" จากค่าเริ่มต้น) — ใช้คู่กันเพื่อ UX ที่ดี

✅ Accessibility (a11y)

a11y = ตัวย่อของ accessibility (a + 11 ตัวอักษร + y) = การออกแบบเว็บให้ใช้ได้กับทุกคน รวมถึงคนที่ใช้ screen reader (โปรแกรมอ่านหน้าจอ) หรืออุปกรณ์ช่วย

tsx
<label htmlFor="email">Email</label>
<input
    id="email"
    aria-invalid={!!errors.email}          // aria-invalid = บอก screen reader ว่า field นี้มี error
    aria-describedby={errors.email ? 'email-error' : undefined}  // เชื่อม field กับ element ที่มีข้อความ error
    {...register('email')}
/>
{errors.email && <span id="email-error" role="alert">{errors.email.message}</span>}
{/* role="alert" = screen reader จะอ่านข้อความนี้ให้ฟังทันทีที่ปรากฏ */}

❌ อย่า validate แบบ "blur" จนผู้ใช้ submit

  • ผู้ใช้ดูได้ตลอดว่า error ตรงไหน
  • mode: 'onBlur' หรือ 'onChange' หลัง submit ครั้งแรก:
tsx
useForm({
    mode: 'onTouched',   // validate หลัง touched (recommended)
    resolver: zodResolver(schema),
});

mode options:

  • 'onSubmit' = validate ตอน submit เท่านั้น (default)
  • 'onBlur' = validate ตอน blur (คลิกออกจาก field)
  • 'onChange' = validate ทุก keystroke (เข้มงวดสุด)
  • 'onTouched' = validate ตอน blur ครั้งแรก แล้วสลับเป็น onChange (recommended)
  • 'all' = validate ทั้ง blur และ change

14. ตัวอย่างใหญ่ — User Settings Form

ปิดท้ายบทด้วยตัวอย่างที่รวมทุกเทคนิค — User Settings Form ที่ใช้ Zod schema, default values (เติมข้อมูลเดิม), nested field, validation และ submit error จาก server ครบ เป็นแบบที่นำไปปรับใช้กับ form จริงในงานได้:

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

const settingsSchema = z.object({
    name: z.string().min(1).max(100),
    email: z.email(),
    bio: z.string().max(500).optional(),
    phone: z.string().optional(),                        // เบอร์โทรสำหรับ SMS notification
    notifications: z.object({
        email: z.boolean(),
        push: z.boolean(),
        sms: z.boolean(),
    }),
    theme: z.enum(['light', 'dark', 'auto']),
    language: z.enum(['en', 'th', 'jp']),
}).refine(
    data => !data.notifications.sms || !!data.phone,     // cross-field: เปิด SMS ต้องมีเบอร์โทร
    { message: "กรุณากรอกเบอร์โทรก่อนเปิด SMS notification", path: ['phone'] }
);

type Settings = z.infer<typeof settingsSchema>;

function SettingsForm({ initial, onSave }: { initial: Settings; onSave: (s: Settings) => Promise<void> }) {
    const {
        register,
        handleSubmit,
        formState: { errors, isDirty, isSubmitting, isValid },
        reset,
        setError,
    } = useForm<Settings>({
        resolver: zodResolver(settingsSchema),
        defaultValues: initial,
        mode: 'onTouched',
    });
    
    const onSubmit = async (data: Settings) => {
        try {
            await onSave(data);
            reset(data);    // mark clean
        } catch (err) {
            setError('root', { message: err instanceof Error ? err.message : 'Error' });
        }
    };
    
    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <h2>Profile</h2>
            <input placeholder="Name" {...register('name')} />
            {errors.name && <span>{errors.name.message}</span>}
            
            <input type="email" {...register('email')} />
            {errors.email && <span>{errors.email.message}</span>}
            
            <textarea placeholder="Bio" {...register('bio')} />
            {errors.bio && <span>{errors.bio.message}</span>}
            
            <input placeholder="เบอร์โทร (ต้องกรอกถ้าจะเปิด SMS)" {...register('phone')} />
            {errors.phone && <span>{errors.phone.message}</span>}
            
            <h2>Notifications</h2>
            <label><input type="checkbox" {...register('notifications.email')} /> Email</label>
            <label><input type="checkbox" {...register('notifications.push')} /> Push</label>
            <label><input type="checkbox" {...register('notifications.sms')} /> SMS</label>
            {errors.notifications?.sms && <span>{errors.notifications.sms.message}</span>}
            
            <h2>Preferences</h2>
            <select {...register('theme')}>
                <option value="light">Light</option>
                <option value="dark">Dark</option>
                <option value="auto">Auto</option>
            </select>
            
            <select {...register('language')}>
                <option value="en">English</option>
                <option value="th">ไทย</option>
                <option value="jp">日本語</option>
            </select>
            
            {errors.root && <p style={{ color: 'red' }}>{errors.root.message}</p>}
            
            <button type="submit" disabled={!isDirty || isSubmitting || !isValid}>
                {isSubmitting ? 'Saving...' : 'Save Changes'}
            </button>
        </form>
    );
}

14.5 React 19 Server Actions — ดูบท 11

💡 ข้ามส่วนนี้ไปก่อนได้ถ้ายังไม่ได้อ่านบท 11 — เนื้อหาในบทนี้ (RHF + Zod) ใช้ได้ครบถ้วนโดยไม่ต้องรู้เรื่อง React 19 Server Actions

React 19 มี pattern ใหม่สำหรับ form (useActionState, useFormStatus, useOptimistic, <form action={...}>) ที่ลด client JS เยอะ — แต่ต้องใช้คู่กับ Server Components / Next.js App Router ที่จะสอนในบท 11 (Next.js)

→ รายละเอียดและตัวอย่างเต็มอยู่ใน บท 11 §20.1 — กลับมาอ่านได้หลังจบบทนี้


15. Checkpoint

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง

🛠️ Checkpoint 3.1 — Sign up + login form

ใช้ React Hook Form + Zod:

  • Sign up: name, email, password (≥8), confirmPassword (ต้อง match)
  • Login: email, password
  • จัดการ submit + error จาก server

🛠️ Checkpoint 3.2 — Dynamic form

ใช้ useFieldArray ทำ "address book" — เพิ่ม/ลบ address ได้

🛠️ Checkpoint 3.3 — Multi-step

ทำ wizard 3 ขั้น:

  1. Personal info (name, email)
  2. Address
  3. Review + submit

16. สรุปบท

✅ Controlled = React คุม value, Uncontrolled = DOM คุม ✅ Form เปล่ามือ — manage state + validate เอง — เหนื่อย ✅ React Hook Form + Zod = stack มาตรฐานปี 2026 ✅ register('field') spread เป็น input — RHF อ่านค่าจาก DOM โดยตรง (ไม่ re-render ทุกการพิมพ์ → เร็ว) ✅ handleSubmit(onSubmit) — validate ก่อนเรียก handler ✅ Zod schema = single source of truth — type inference ฟรี ✅ useFieldArray สำหรับ array of objects (dynamic fields) ✅ Controller สำหรับ custom UI (MUI, Antd) ✅ setError, reset, trigger, watch ใช้บ่อย ✅ mode: 'onTouched' ให้ UX ดี (validate หลัง user แตะ field) ✅ React 19 alternative: useActionState + Server Action (ดู ch.11 §20.1) — pattern ใหม่ที่ลด client JS ✅ Alternative libraries ปี 2026: TanStack Form (headless = ให้แค่ logic ไม่บังคับหน้าตา UI เราจัด UI เอง, type-safe), Conform (progressive enhancement-first = ทำงานได้แม้ JS ยังไม่โหลด — เน้นให้ form ใช้ได้แม้ browser ปิด JS)

→ ไปบทที่ 4: Data Fetching