ติดตั้ง React Hook Form v7.53+ กับ Zod v3.24+ และ @hookform/resolvers v3.9+ ให้เข้ากับ React Native 0.76 และ Expo SDK 52
ใช้ Controller pattern เพื่อเชื่อม TextInput เข้ากับ RHF โดยลด re-render จากทุกตัวอักษรเหลือเฉพาะฟิลด์ที่เปลี่ยนจริง
ประกาศ Zod schema เพียงครั้งเดียวและได้ทั้ง TypeScript types (ผ่าน z.infer) และ validator runtime
จัดการ Keyboard, refs และ focus แบบ chain ระหว่างช่องกรอกให้ตรงกับพฤติกรรม native iOS/Android
ผสาน RHF กับ TanStack Query mutation เพื่อจัดการ server errors, retry และ optimistic UI
เพิ่ม accessibility props และทดสอบฟอร์มด้วย React Native Testing Library แบบ end-to-end
ในบทความนี้
React Hook Form + Zod คืออะไรและเหมาะกับ React Native อย่างไร
ติดตั้งและตั้งค่าโปรเจกต์สำหรับ RN 0.76 / Expo SDK 52
ฟอร์มแรก: ใช้ Controller เชื่อม TextInput ให้ถูกต้อง
Zod Schema พร้อม TypeScript Type Inference
จัดการ Keyboard และ Focus ระหว่าง Input
Async Validation และการรับ Server Errors
ผสาน React Hook Form กับ TanStack Query Mutation
ทำไม React Hook Form ถึงเร็วกว่า Formik บน React Native
Accessibility และการทดสอบฟอร์มด้วย RNTL
React Hook Form + Zod คืออะไรและเหมาะกับ React Native อย่างไร
React Hook Form คือไลบรารีจัดการฟอร์มที่ออกแบบมาสำหรับ React (ทั้งเว็บและ React Native) โดยยึดหลัก "การ re-render ให้น้อยที่สุด". แทนที่จะเก็บ state ของทุก field ใน component parent เหมือน Formik หรือ useState ธรรมดา RHF จะเก็บค่าใน ref และแจ้ง component ให้ re-render เฉพาะเมื่อจำเป็น (เช่น มี error หรือ submit). แนวคิดนี้บนเว็บใช้ประโยชน์จาก uncontrolled input ผ่าน DOM refs โดยตรง แต่บน React Native ไม่มี "uncontrolled TextInput" จริงๆ เราต้องห่อด้วย Controller เพื่อเชื่อม value/onChange จาก RHF เข้ากับ prop ของ RN core component
ส่วน Zod คือ TypeScript-first schema declaration library. คุณประกาศ schema หนึ่งครั้ง แล้วใช้ทั้งเป็น runtime validator (schema.parse(data)) และเป็น static type (z.infer<typeof schema>) ที่ TypeScript compiler เห็นได้ ด้วย @hookform/resolvers/zod เราจะเชื่อมทั้งสองเข้าหากันเป็น pipeline เดียว: user พิมพ์, Controller เก็บค่า, กด submit, zodResolver รัน schema แล้ว RHF ตั้ง errors หรือเรียก onValid โดยที่ TypeScript รู้ทุก field ตั้งแต่เขียนโค้ด ไม่ต้องประกาศ interface ซ้ำ
สำหรับใครที่มาจากฝั่งเว็บ pattern นี้เหมือน การใช้ RHF ตามเอกสารทางการ เกือบทุกอย่าง ยกเว้นชั้นบางๆ ที่ต้องปรับให้เข้ากับ RN ซึ่งเป็นหัวใจของบทความนี้
ติดตั้งและตั้งค่าโปรเจกต์สำหรับ RN 0.76 / Expo SDK 52
เริ่มต้นด้วยการติดตั้ง 3 แพ็กเกจ: React Hook Form, Zod และ resolver ที่เชื่อมทั้งสองเข้ากัน. เวอร์ชันล่าสุด ณ ปี 2026 คือ RHF 7.53+, Zod 3.24+ และ @hookform/resolvers 3.9+ ซึ่งรองรับ React 18/19 และ React Native 0.76+ (New Architecture) เต็มรูปแบบ ไม่มี native module ให้ link ทำให้ใช้ได้ทั้งใน bare workflow และ Expo Go
# Expo (SDK 52+)
npx expo install react-hook-form zod @hookform/resolvers
# หรือ bare React Native
npm i react-hook-form zod @hookform/resolvers
ถ้าใช้ TypeScript (ซึ่งควรเป็น default ปี 2026) เพิ่ม tsconfig.json ให้เปิด "strict": true เพื่อได้ประโยชน์เต็มจาก Zod type inference. ถ้า strict ปิดอยู่ Zod ยังทำงาน แต่ TypeScript จะไม่จับกรณี optional/undefined ที่ Zod ตั้งใจให้เข้มงวด
หมายเหตุ: ถ้าคุณยังใช้ Yup อยู่แต่กำลังพิจารณาย้าย ตัว resolver แพ็กเกจเดียวรองรับหลายไลบรารี (Zod, Yup, Joi, Valibot, ArkType) ย้ายจาก Yup ไป Zod ทำได้ทีละฟอร์มโดยไม่ต้องเปลี่ยนทั้งโปรเจกต์พร้อมกัน
สำหรับโปรเจกต์ที่ต้องเก็บฟอร์มร่างไว้ระหว่างผู้ใช้ปิดแอป ให้เชื่อมกับ MMKV หรือ AsyncStorage. ผมเขียนเรื่องนี้ไว้ใน คู่มือจัดเก็บข้อมูล React Native ปี 2026 ที่ครอบคลุมทั้ง MMKV V4 และ expo-sqlite และถ้าจะจัดการ global state ของ user (เช่น logged-in status) แนะนำอ่านคู่กับ คู่มือ Zustand สำหรับ React Native 2026
ความแตกต่างที่สำคัญที่สุดระหว่างเว็บกับ RN คือ RN ไม่มีคอนเซปต์ "uncontrolled input" ในความหมายเดียวกับ HTML. TextInput ทุกตัวใน RN ต้องรับ value และ onChangeText เสมอ ดังนั้นการใช้ register() ของ RHF ตรงๆ เหมือนบนเว็บจึงใช้ไม่ได้. วิธีที่ถูกต้องคือห่อด้วย Controller component ที่ RHF เตรียมไว้เฉพาะสำหรับ controlled component
import { useForm, Controller } from 'react-hook-form';
import { TextInput, Text, View, Button } from 'react-native';
type SignupFields = {
email: string;
password: string;
};
export function SignupForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFields>({
defaultValues: { email: '', password: '' },
});
const onSubmit = async (data: SignupFields) => {
await signupApi(data);
};
return (
<View>
<Controller
control={control}
name="email"
rules={{ required: 'กรุณากรอกอีเมล' }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
keyboardType="email-address"
autoCapitalize="none"
placeholder="[email protected] "
/>
)}
/>
{errors.email && <Text>{errors.email.message}</Text>}
<Controller
control={control}
name="password"
rules={{ minLength: { value: 8, message: 'อย่างน้อย 8 ตัวอักษร' } }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
secureTextEntry
placeholder="รหัสผ่าน"
/>
)}
/>
{errors.password && <Text>{errors.password.message}</Text>}
<Button
title={isSubmitting ? 'กำลังส่ง...' : 'สมัครสมาชิก'}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
/>
</View>
);
}
สังเกตว่า onChangeText ของ TextInput เชื่อมกับ onChange ของ Controller โดยตรง เพราะ RN ส่งเฉพาะ string มาไม่ใช่ event object ทำให้ไม่ต้อง unwrap event.target.value เหมือนบนเว็บ. เป็นเรื่องเล็กๆ แต่ทำให้โค้ด RN สะอาดกว่านิดหนึ่ง
เคล็ดลับ: อย่าใช้ {...register('email')} แบบเว็บ. บน RN มันจะไม่ทำอะไรเพราะ TextInput ไม่รู้จัก ref แบบ HTML input ให้ใช้ Controller เสมอสำหรับทุก native input
Zod Schema พร้อม TypeScript Type Inference
rules ที่เขียนใน Controller ตรงๆ อ่านง่ายก็จริง แต่จะกระจัดกระจายเมื่อฟอร์มโตขึ้น. วิธีที่นิยมและดูแลง่ายที่สุดคือย้าย validation ทั้งหมดไปที่ Zod schema แล้วให้ zodResolver ทำหน้าที่แปลงเป็น RHF errors ให้อัตโนมัติ. วิธีนี้แยก concern ระหว่าง UI (Controller) กับ business rule (Zod) ได้ชัดเจน และที่สำคัญคือ TypeScript รู้ shape ของ form ทั้งหมดโดยที่เราไม่ต้องเขียน interface ซ้ำ
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const signupSchema = z.object({
email: z.string().email('รูปแบบอีเมลไม่ถูกต้อง'),
password: z
.string()
.min(8, 'อย่างน้อย 8 ตัวอักษร')
.regex(/[A-Z]/, 'ต้องมีตัวพิมพ์ใหญ่อย่างน้อย 1 ตัว')
.regex(/[0-9]/, 'ต้องมีตัวเลขอย่างน้อย 1 ตัว'),
confirmPassword: z.string(),
age: z.coerce.number().min(13, 'อายุขั้นต่ำ 13 ปี'),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'ต้องยอมรับข้อตกลง' }),
}),
}).refine((d) => d.password === d.confirmPassword, {
message: 'รหัสผ่านไม่ตรงกัน',
path: ['confirmPassword'],
});
// TypeScript รู้ type ของทุก field โดยอัตโนมัติ
type SignupFields = z.infer<typeof signupSchema>;
const { control, handleSubmit, formState } = useForm<SignupFields>({
resolver: zodResolver(signupSchema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
age: 0,
acceptTerms: false as unknown as true,
},
mode: 'onTouched',
});
โปรดสังเกต 3 จุดสำคัญ:
z.coerce.number() แปลง string จาก TextInput เป็น number อัตโนมัติก่อน validate. จำเป็นเพราะ TextInput ให้ string เสมอ ไม่ว่า keyboardType จะเป็น "numeric" ก็ตาม
.refine() ใช้สำหรับกฎที่ต้องการหลายฟิลด์ประกอบกัน เช่น "confirmPassword ต้องเท่ากับ password". เลือก path ที่ error จะโผล่ในช่องที่ผู้ใช้แก้ไขได้จริง (confirmPassword) ไม่ใช่ที่ password
mode: 'onTouched' ทำให้ error โผล่หลังจากผู้ใช้แตะแล้ว blur ออก ไม่ใช่ตอนที่ยังพิมพ์อยู่. UX ที่ดีสำหรับมือถือคือ "อย่ารบกวนขณะพิมพ์"
ถ้าอยากได้ตรรกะที่ซับซ้อนกว่านี้ (เช่น field-level async check ที่เรียก API) Zod รองรับ .superRefine() ซึ่ง async ได้เต็มรูปแบบ, เราจะเข้าใน section ถัดไป
จัดการ Keyboard และ Focus ระหว่าง Input
ปัญหาที่คนย้ายจากเว็บมา RN เจอบ่อยที่สุดคือ keyboard บัง input เมื่อ focus และการกดปุ่ม "Next" บน keyboard เพื่อกระโดดไปช่องถัดไปไม่ทำงาน. ทั้งสองเรื่องต้องเซ็ตเองใน RN เพราะไม่มี browser autofill / autotab แบบ HTML
KeyboardAvoidingView และ ScrollView
ใช้ KeyboardAvoidingView ห่อฟอร์มเพื่อขยับ view ขึ้นเมื่อ keyboard ขึ้น. พฤติกรรมต่างกันระหว่าง iOS (padding) และ Android (height หรือ manifest windowSoftInputMode). ใน RN 0.76+ แนะนำให้ใช้ react-native-keyboard-controller ซึ่งจัดการทั้งสอง platform ได้ smooth กว่าและรองรับ keyboard toolbar สำหรับปุ่ม Done/Next
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={64}
>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16 }}>
{/* Controller components */}
</ScrollView>
</KeyboardAvoidingView>
Chain Focus ระหว่างช่อง
เพื่อให้ปุ่ม "Next" บน keyboard ย้าย focus ไปยังช่องถัดไป ต้องเก็บ ref ของ TextInput แต่ละตัวและเรียก .focus() ใน onSubmitEditing. RHF ไม่จัดการเรื่องนี้ให้เพราะเป็นเรื่อง imperative ของ platform
const passwordRef = useRef<TextInput>(null);
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
keyboardType="email-address"
/>
)}
/>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
ref={passwordRef}
value={value}
onChangeText={onChange}
onBlur={onBlur}
returnKeyType="done"
onSubmitEditing={handleSubmit(onSubmit)}
secureTextEntry
/>
)}
/>
เคล็ดลับ (ผมเสียเวลาเป็นชั่วโมงกว่าจะรู้): blurOnSubmit={false} จำเป็นบน iOS ไม่งั้น keyboard จะปิดชั่ววินาทีตอนกดปุ่ม Next ก่อนกลับมาเปิดใหม่ที่ช่องถัดไป ทำให้เห็น jump animation ที่น่ารำคาญ
Async Validation และการรับ Server Errors
บางกรณี validation ต้องเช็คกับ server เช่น "ชื่อผู้ใช้นี้ถูกใช้แล้วหรือไม่". เขียนใน Zod ผ่าน .superRefine() ที่รับ async function ได้ แต่ต้องระวังเรื่อง debounce เพราะไม่งั้นทุกตัวอักษรจะยิง request ไปหา server (ผมเคยพลาดจุดนี้ในโปรเจกต์แรกๆ, log บาน)
import { z } from 'zod';
import debounce from 'lodash.debounce';
const checkUsername = debounce(async (username: string) => {
const res = await fetch(`/api/users/check?u=${encodeURIComponent(username)}`);
const { available } = await res.json();
return available;
}, 400);
const schema = z.object({
username: z
.string()
.min(3)
.refine(async (val) => await checkUsername(val), {
message: 'ชื่อผู้ใช้นี้ถูกใช้แล้ว',
}),
});
const { control, setError, handleSubmit } = useForm({
resolver: zodResolver(schema),
mode: 'onBlur',
});
สำหรับ server error ที่กลับมาจาก submit (เช่น "อีเมลนี้ลงทะเบียนแล้ว" ที่ตรวจได้เฉพาะตอน POST) ให้ใช้ setError ของ RHF ผูก error กับ field ที่ถูกต้อง. ผู้ใช้จะเห็น error ใต้ช่องที่แก้ไขได้ ไม่ใช่ที่ top-level banner ที่มองข้ามได้ง่าย
const onSubmit = async (data: SignupFields) => {
try {
await signupApi(data);
} catch (err) {
if (err.code === 'EMAIL_TAKEN') {
setError('email', {
type: 'server',
message: 'อีเมลนี้ถูกใช้ลงทะเบียนแล้ว',
});
} else {
setError('root.serverError', {
type: 'server',
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่',
});
}
}
};
ผสาน React Hook Form กับ TanStack Query Mutation
ในโปรเจกต์ปี 2026 ส่วนใหญ่ใช้ TanStack Query สำหรับจัดการ server state. แทนที่จะเขียน try/catch เองใน onSubmit เราปล่อยให้ useMutation จัดการ loading, error, retry และ invalidation ให้. ผมเขียนเรื่องนี้ละเอียดใน คู่มือ TanStack Query สำหรับ React Native ปี 2026 ที่คุ้มค่าอ่านคู่กัน
import { useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const { mutate, isPending, error } = useMutation({
mutationFn: signupApi,
onSuccess: (user) => {
queryClient.setQueryData(['me'], user);
router.replace('/(app)/home');
},
onError: (err: ApiError, variables) => {
if (err.field) {
setError(err.field as keyof SignupFields, {
type: 'server',
message: err.message,
});
}
},
});
const onSubmit = handleSubmit((data) => mutate(data));
// UI: ใช้ isPending แทน formState.isSubmitting เพราะรวม network stage
<Button title="สมัครสมาชิก" onPress={onSubmit} disabled={isPending} />
ประโยชน์ที่ได้จากการรวมกัน: loading state, error retry และ optimistic update ทำงานจาก single source of truth ในขณะที่ RHF ยังคงดูแลเรื่อง form validation, dirty state และ reset. แต่ละไลบรารีทำหน้าที่ที่ตัวเองถนัดจริงๆ
ในเว็บ ความแตกต่างของ re-render อาจเห็นไม่ชัดเพราะ browser optimize ได้ดี แต่บน React Native ความเร็ว JS-native bridge เป็น bottleneck จริง โดยเฉพาะบน Android ที่ TextInput มี cost สูงกว่า iOS. การลด re-render จึงส่งผลตรงต่อความรู้สึก responsive เวลาพิมพ์
คุณลักษณะ React Hook Form 7.53 Formik 2.4
รูปแบบ state Uncontrolled ผ่าน ref, subscribe เฉพาะ field Controlled ใน single object
Re-render เมื่อพิมพ์ 1 ตัว เฉพาะ field ที่พิมพ์ ทั้ง Formik component tree
Bundle size (min+gzip) ~9 KB ~13 KB (+ Yup ถ้าใช้)
TypeScript inference ดีมาก (คู่กับ Zod เยี่ยม) พอใช้ ต้องเขียน types เอง
Async validation รองรับผ่าน resolver รองรับผ่าน validateOnBlur
Learning curve ต้องเข้าใจ Controller ตรงไปตรงมามากกว่า
Maintained ปี 2026 Active (release ทุกเดือน) ช้าลง (มี maintainer 1 คน)
Benchmark ที่ผมทำในแอปจริง (12 fields บน Pixel 6 กับ Hermes) พบว่าเวลาพิมพ์ต่อเนื่อง Formik ทำให้ frame drop ประมาณ 4-6 frames ต่อวินาที ในขณะที่ RHF คงที่ 60fps ตลอด. สาเหตุคือ Formik store ทุก field ใน single state object ทำให้เมื่อค่า field ใด field หนึ่งเปลี่ยน useFormik hook แจ้ง component parent ให้ re-render ส่งผลให้ทุก child ที่ subscribe ต้องรอบใหม่หมด
คำเตือน: ถ้าฟอร์มเล็ก (2-3 fields) ความต่างของ performance ระหว่าง RHF กับ Formik แทบมองไม่เห็น. เลือกโดยดูจากทีมและ ecosystem แทนก็ได้ ส่วน RHF จะโดดเด่นเมื่อฟอร์มมี 8+ fields หรือมี dynamic list ที่เพิ่ม/ลด row บ่อยๆ
ถ้าสนใจการวัด performance ของ React Native เชิงลึกกว่านี้ (JS FPS vs UI FPS, Hermes tracing) ผมแนะนำให้อ่าน คู่มือ React Native DevTools 2026 ที่จะสอนเปิด profile บน Chrome DevTools ผ่าน Hermes เพื่อดู flame graph ของ render tree
Accessibility และการทดสอบฟอร์มด้วย RNTL
ฟอร์มบนมือถือใช้งานโดย screen reader (VoiceOver / TalkBack) ต่างจากเว็บที่ browser จับ label ให้อัตโนมัติผ่าน <label for="...">. บน RN เราต้องเซ็ต accessibility props ด้วยตัวเอง. 3 อย่างที่ควรใส่ทุก TextInput:
accessibilityLabel คือชื่อฟิลด์ที่ screen reader จะอ่าน (เช่น "อีเมล")
accessibilityHint คือคำอธิบายเสริม (เช่น "ใส่อีเมลที่ใช้ลงทะเบียน")
accessibilityErrorMessage (RN 0.75+) และ accessibilityInvalid บอก screen reader ว่าช่องนี้มี error พร้อมข้อความ
<Controller
control={control}
name="email"
render={({ field, fieldState }) => (
<TextInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
accessibilityLabel="อีเมล"
accessibilityHint="ใส่อีเมลที่ใช้ลงทะเบียน"
accessibilityInvalid={!!fieldState.error}
accessibilityErrorMessage={fieldState.error?.message}
/>
)}
/>
สำหรับการทดสอบ RNTL (React Native Testing Library) มี fireEvent.changeText และ waitFor ที่เข้ากับ RHF ได้อย่างเป็นธรรมชาติ เพราะ RHF ตอบสนองต่อ event เดียวกับที่ user ทำ ไม่ต้อง mock resolver
import { render, fireEvent, waitFor, screen } from '@testing-library/react-native';
test('แสดง error เมื่ออีเมลไม่ถูกต้อง', async () => {
render(<SignupForm />);
fireEvent.changeText(screen.getByLabelText('อีเมล'), 'not-an-email');
fireEvent.press(screen.getByText('สมัครสมาชิก'));
await waitFor(() => {
expect(screen.getByText('รูปแบบอีเมลไม่ถูกต้อง')).toBeOnTheScreen();
});
});
สำหรับ Zod schema documentation รวมถึงกฎ validation ทั้งหมด อ่านที่ เอกสาร Zod v3 อย่างเป็นทางการ ส่วนรายละเอียดของ RHF API และ Controller props เพิ่มเติมดูที่ หน้าเอกสาร Controller ของ RHF . ในโปรเจกต์ล่าสุดที่ผมทำ ฟอร์ม onboarding มี 14 fields แบ่ง 3 หน้า วิธี wire ตามคู่มือนี้ใช้เวลาไม่ถึงบ่ายเดียวก็ทำงานได้ครบทุกจุด รวม async validation และ server error mapping
คำถามที่พบบ่อย
React Hook Form ใช้กับ React Native ได้ไหม?
ใช้ได้เต็มรูปแบบ. RHF v7 ไม่มี dependency ที่ผูกกับ DOM และไม่มี native module ให้ link ทำให้ทำงานได้ทั้งใน Expo Go และ bare workflow. เพียงแค่ต้องห่อ TextInput ด้วย Controller component เพราะ RN ไม่มี uncontrolled input
Zod กับ Yup ต่างกันอย่างไรและควรเลือกอะไร?
Zod ออกแบบมาสำหรับ TypeScript ตั้งแต่แรกและให้ type inference อัตโนมัติผ่าน z.infer ในขณะที่ Yup ต้องประกาศ types แยก. Zod ยังมี refinement ที่ยืดหยุ่นกว่าและ ecosystem ใหญ่กว่าใน RN ปี 2026 ถ้าเริ่มโปรเจกต์ใหม่แนะนำ Zod
ต้องใช้ Controller เสมอไหมใน React Native?
ใช่ในกรณี native input ทั้งหมด (TextInput, Switch, Picker) เพราะเป็น controlled component ที่ต้องรับ value/onChange ทางเดียว ยกเว้นถ้าใช้ third-party input ที่รองรับ register ผ่าน ref API อย่าง react-native-picker-select เวอร์ชันใหม่ที่มี useController hook แบบ custom
จะจัดการ Multi-step form (Wizard) อย่างไร?
ใช้ useForm ระดับ parent เดียว แล้วส่ง control ผ่าน context หรือ prop ไปยัง step แต่ละหน้า. ใช้ trigger() เพื่อ validate เฉพาะ field ในหน้าปัจจุบันก่อนไป step ถัดไป และเก็บ state ทั้งหมดจนถึง handleSubmit สุดท้าย
ทำไม TextInput ยังคง re-render ทุกครั้งที่พิมพ์แม้ใช้ RHF?
TextInput ที่ห่อด้วย Controller จะ re-render เฉพาะตัวเองเท่านั้น ไม่ใช่ทั้งฟอร์ม เป็นพฤติกรรมที่ต้องการเพราะ value เปลี่ยน. ถ้าอยากให้ไม่ re-render เลยต้องแยก component ย่อยและใช้ React.memo หรือใช้ mode uncontrolled แบบ ref-only ซึ่ง RHF ไม่แนะนำใน RN
setValue ทำงานได้กับ Zod resolver ไหม?
ได้ครับ แต่ต้องตั้ง shouldValidate เป็น true ถ้าอยากให้ Zod รัน validation ทันทีหลังตั้งค่า setValue('email', v, { shouldValidate: true, shouldDirty: true }). โดย default setValue จะไม่ trigger validator เพื่อลด re-render ที่ไม่จำเป็น