โหมดมืด
บทที่ 3 — Form + Validation
🟡 ระดับ: กลาง — ควรผ่านบท 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)(แทน methoderror.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 ให้ ทำให้เจอ errorReferenceError: 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 แล้ว (ค่า "เลอะ" จากค่าเริ่มต้น) — ถ้าไม่มีการเปลี่ยน ไม่ต้อง submitisValid= ผ่าน 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 ขั้น:
- Personal info (name, email)
- Address
- 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)