React Hook Form ve Zod v4 ile Type-Safe Form Validation Rehberi
Form yönetimi, her React geliştiricisinin kariyerinde en az bir kez baş ağrısına dönüşen konuların başında gelir. State yönetimi, doğrulama kuralları, hata mesajları, performans optimizasyonu… Liste uzayıp gider. Ancak React Hook Form ve Zod v4 kombinasyonu, bu karmaşıklığı zarif bir şekilde çözerek form geliştirme deneyimini tamamen dönüştürüyor.
Bu yazıda, bu iki kütüphanenin neden birlikte kullanılması gerektiğini, Zod v4'ün getirdiği yenilikleri ve gerçek dünya senaryolarında nasıl uygulanacağını derinlemesine inceleyeceğiz.
Neden React Hook Form + Zod?
Geleneksel form yönetiminde karşılaştığımız temel sorunları şöyle sıralayabiliriz:
- Gereksiz re-render'lar: Controlled component'lerde her tuş vuruşunda tüm form yeniden render olur.
- Tip güvensizliği: Form verilerinin TypeScript ile uyumlu olması için ekstra efor gerekir.
- Doğrulama mantığının dağınıklığı: Validation kuralları component'lerin içine gömülür ve tekrar kullanılamaz.
- Runtime ve compile-time uyumsuzluğu: TypeScript tipleri ile runtime doğrulama kuralları arasında senkronizasyon sorunu yaşanır.
React Hook Form, uncontrolled component yaklaşımıyla performans sorununu ortadan kaldırır. Zod ise schema-first validation yaklaşımıyla hem runtime doğrulama hem de TypeScript tip çıkarımı sağlar. İkisini birleştirdiğinizde, "tek bir kaynak noktasından" (single source of truth) hem tip tanımını hem de doğrulama kurallarını yönetirsiniz.
Zod v4: Neler Değişti?
Zod v4, Mayıs 2025'te yayınlanan büyük bir sürüm güncellemesidir. Önceki sürümlere göre dikkat çekici iyileştirmeler sunar:
- ~2x daha hızlı string doğrulama ve genel performans artışı
- ~50% daha küçük bundle size (13kB → ~7kB gzipped)
- Yeni
z.interface()API'si ile genişletilebilir nesne şemaları z.templateLiteral()ile template literal tip desteği- Geliştirilmiş hata mesajları ve
z.prettifyError()yardımcı fonksiyonu - JSON Schema desteği (
z.toJSONSchema())
Bu iyileştirmeler, özellikle form-ağırlıklı uygulamalarda gözle görülür fark yaratır.
Kurulum
Projenize gerekli paketleri ekleyerek başlayalım:
npm install react-hook-form zod @hookform/resolversNot:
@hookform/resolverspaketi, Zod v4 desteğini içerir. En güncel sürümü kullandığınızdan emin olun.
Temel Kullanım: İlk Formunuz
Basit bir kayıt formu oluşturarak temel yapıyı anlayalım:
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. Schema tanımı — tek kaynak noktası
const registerSchema = z.object({
username: z
.string()
.min(3, 'Kullanıcı adı en az 3 karakter olmalıdır')
.max(20, 'Kullanıcı adı en fazla 20 karakter olabilir')
.regex(/^[a-zA-Z0-9_]+$/, 'Sadece harf, rakam ve alt çizgi kullanılabilir'),
email: z
.string()
.email('Geçerli bir e-posta adresi giriniz'),
password: z
.string()
.min(8, 'Şifre en az 8 karakter olmalıdır')
.regex(/[A-Z]/, 'En az bir büyük harf içermelidir')
.regex(/[0-9]/, 'En az bir rakam içermelidir'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Şifreler eşleşmiyor',
path: ['confirmPassword'],
});
// 2. Schema'dan tip çıkarımı
type RegisterFormData = z.infer<typeof registerSchema>;
// 3. Component
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
},
});
const onSubmit = async (data: RegisterFormData) => {
// data burada tam tip güvenli!
console.log(data);
await submitToAPI(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="username">Kullanıcı Adı</label>
<input id="username" {...register('username')} />
{errors.username && (
<span className="error">{errors.username.message}</span>
)}
</div>
<div>
<label htmlFor="email">E-posta</label>
<input id="email" type="email" {...register('email')} />
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="password">Şifre</label>
<input id="password" type="password" {...register('password')} />
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">Şifre Tekrar</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Gönderiliyor...' : 'Kayıt Ol'}
</button>
</form>
);
}Bu örnekte dikkat edilmesi gereken kritik noktalar:
- Schema, tipin kaynağıdır.
RegisterFormDatatipi schema'dan türetilir; ayrı bir interface tanımlamaya gerek yoktur. refineile cross-field validation yapılabilir. Şifre eşleşme kontrolü gibi birden fazla alana bağlı doğrulamalar schema seviyesinde ele alınır.registerfonksiyonu tip güvenlidir. Yanlış bir alan adı yazarsanız TypeScript derleme hatası verir.
Zod v4'ün Yeni Özelliklerini Form'larda Kullanmak
z.interface() ile Genişletilebilir Şemalar
Zod v4'ün z.interface() API'si, z.object()'ten farklı olarak nesne şemalarını extends ile genişletmenize olanak tanır. Bu, büyük uygulamalarda ortak form alanlarını paylaşmak için idealdir:
import { z } from 'zod';
// Ortak alanlar
const baseUserSchema = z.interface({
email: z.string().email('Geçerli bir e-posta giriniz'),
name: z.string().min(2, 'İsim en az 2 karakter olmalıdır'),
});
// Kayıt formu — base'i genişletir
const registrationSchema = baseUserSchema.extend({
password: z.string().min(8),
role: z.enum(['user', 'admin']),
});
// Profil güncelleme formu — base'i genişletir
const profileUpdateSchema = baseUserSchema.extend({
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
});
type RegistrationData = z.infer<typeof registrationSchema>;
type ProfileUpdateData = z.infer<typeof profileUpdateSchema>;z.templateLiteral() ile Özel Format Doğrulama
Belirli formatlardaki string'leri doğrulamak için Zod v4'ün yeni z.templateLiteral() özelliğini kullanabilirsiniz:
const hexColorSchema = z.templateLiteral([
z.literal('#'),
z.string().regex(/^[0-9a-fA-F]{6}$/),
]);
const themeSchema = z.object({
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
fontSize: z.number().min(12).max(24),
});İleri Seviye: Dinamik ve Koşullu Formlar
Gerçek dünya uygulamalarında formlar nadiren statiktir. Kullanıcı seçimlerine göre alanların değiştiği senaryolara bakalım:
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const paymentSchema = z.discriminatedUnion('paymentMethod', [
z.object({
paymentMethod: z.literal('creditCard'),
cardNumber: z
.string()
.regex(/^\d{16}$/, 'Kart numarası 16 haneli olmalıdır'),
expiryDate: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, 'GG/YY formatında giriniz'),
cvv: z.string().regex(/^\d{3,4}$/, 'CVV 3 veya 4 haneli olmalıdır'),
}),
z.object({
paymentMethod: z.literal('bankTransfer'),
iban: z
.string()
.regex(/^TR\d{24}$/, 'Geçerli bir IBAN giriniz'),
accountHolder: z.string().min(3, 'Hesap sahibi adı gereklidir'),
}),
z.object({
paymentMethod: z.literal('crypto'),
walletAddress: z.string().min(26, 'Geçerli bir cüzdan adresi giriniz'),
network: z.enum(['ethereum', 'bitcoin', 'solana']),
}),
]);
type PaymentFormData = z.infer<typeof paymentSchema>;
export function PaymentForm() {
const { register, handleSubmit, control, formState: { errors } } =
useForm<PaymentFormData>({
resolver: zodResolver(paymentSchema),
defaultValues: {
paymentMethod: 'creditCard',
},
});
const paymentMethod = useWatch({ control, name: 'paymentMethod' });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<select {...register('paymentMethod')}>
<option value="creditCard">Kredi Kartı</option>
<option value="bankTransfer">Banka Transferi</option>
<option value="crypto">Kripto Para</option>
</select>
{paymentMethod === 'creditCard' && (
<>
<input {...register('cardNumber')} placeholder="Kart Numarası" />
{errors.cardNumber && <span>{errors.cardNumber.message}</span>}
<input {...register('expiryDate')} placeholder="AA/YY" />
{errors.expiryDate && <span>{errors.expiryDate.message}</span>}
<input {...register('cvv')} placeholder="CVV" />
{errors.cvv && <span>{errors.cvv.message}</span>}
</>
)}
{paymentMethod === 'bankTransfer' && (
<>
<input {...register('iban')} placeholder="IBAN" />
{errors.iban && <span>{errors.iban.message}</span>}
<input {...register('accountHolder')} placeholder="Hesap Sahibi" />
{errors.accountHolder && <span>{errors.accountHolder.message}</span>}
</>
)}
{paymentMethod === 'crypto' && (
<>
<input {...register('walletAddress')} placeholder="Cüzdan Adresi" />
{errors.walletAddress && <span>{errors.walletAddress.message}</span>}
<select {...register('network')}>
<option value="ethereum">Ethereum</option>
<option value="bitcoin">Bitcoin</option>
<option value="solana">Solana</option>
</select>
{errors.network && <span>{errors.network.message}</span>}
</>
)}
<button type="submit">Ödemeyi Tamamla</button>
</form>
);
}discriminatedUnion kullanımı burada hayat kurtarıcıdır. Her ödeme yöntemi için farklı doğrulama kuralları uygulanırken, TypeScript tam tip güvenliği sağlar.
Tekrar Kullanılabilir Form Bileşeni Mimarisi
Büyük projelerde her form alanı için tekrarlanan boilerplate kodu azaltmak isteyeceksiniz. Generic bir FormField bileşeni oluşturalım:
import { useFormContext, FieldPath, FieldValues } from 'react-hook-form';
interface FormFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
type?: string;
placeholder?: string;
}
export function FormField<T extends FieldValues>({
name,
label,
type = 'text',
placeholder,
}: FormFieldProps<T>) {
const {
register,
formState: { errors },
} = useFormContext<T>();
const error = errors[name];
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
placeholder={placeholder}
{...register(name)}
className={error ? 'input-error' : ''}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${name}-error` : undefined}
/>
{error && (
<span id={`${name}-error`} className="error" role="alert">
{error.message as string}
</span>
)}
</div>
);
}Bu bileşeni FormProvider ile birlikte kullanarak form'larınızı çok daha temiz hale getirebilirsiniz:
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function CleanRegisterForm() {
const methods = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormField<RegisterFormData> name="username" label="Kullanıcı Adı" />
<FormField<RegisterFormData> name="email" label="E-posta" type="email" />
<FormField<RegisterFormData> name="password" label="Şifre" type="password" />
<button type="submit">Kayıt Ol</button>
</form>
</FormProvider>
);
}Async Validation: Sunucu Tarafı Doğrulama
Kullanıcı adı veya e-posta gibi benzersiz olması gereken alanları sunucuya sorarak doğrulayabilirsiniz:
const asyncRegisterSchema = z.object({
username: z
.string()
.min(3, 'En az 3 karakter')
.refine(
async (username) => {
const response = await fetch(
`/api/check-username?q=${encodeURIComponent(username)}`
);
const { available } = await response.json();
return available;
},
{ message: 'Bu kullanıcı adı zaten alınmış' }
),
email: z.string().email(),
});React Hook Form'da async validation'ı etkinleştirmek için resolver modunu ayarlayın:
const form = useForm({
resolver: zodResolver(asyncRegisterSchema),
mode: 'onBlur', // Alan focus kaybettiğinde doğrula
});Performans İpuçları
React Hook Form + Zod kombinasyonundan maksimum verimi almak için şu pratikleri uygulayın:
- Schema'ları component dışında tanımlayın. Her render'da yeni schema oluşturmak gereksiz yük yaratır.
mode: 'onBlur'veyamode: 'onSubmit'tercih edin.onChangemodu her tuş vuruşunda validation tetikler ve büyük schema'larda performansı etkileyebilir.useWatchyerinewatchkullanırken dikkatli olun.useWatchyalnızca izlenen alanlar değiştiğinde re-render tetikler.- Zod v4'ün küçük bundle size'ından yararlanın. v3'ten v4'e geçiş, yaklaşık %50 bundle size azalması sağlar.
z.lazy()ile recursive schema'larda dikkatli olun; karmaşık iç içe formlarda gereksiz hesaplamalardan kaçının.
Test Stratejisi
Schema'larınızı bağımsız olarak test edebilmek, bu mimarinin en büyük avantajlarından biridir:
import { describe, it, expect } from 'vitest';
describe('registerSchema', () => {
it('geçerli veriyi kabul etmeli', () => {
const result = registerSchema.safeParse({
username: 'john_doe',
email: 'john@example.com',
password: 'Secret1234',
confirmPassword: 'Secret1234',
});
expect(result.success).toBe(true);
});
it('eşleşmeyen şifreleri reddetmeli', () => {
const result = registerSchema.safeParse({
username: 'john_doe',
email: 'john@example.com',
password: 'Secret1234',
confirmPassword: 'DifferentPass1',
});
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('confirmPassword');
}
});
});Schema testleri React component render'ına ihtiyaç duymaz, bu da test süitinizi çok daha hızlı çalıştırır.
Sonuç
React Hook Form ve Zod v4 kombinasyonu, modern React uygulamalarında form validation için güçlü bir standart sunar. Bu yazıda ele aldığımız temel noktaları özetleyelim:
- Tek kaynak noktası: Zod schema'ları hem runtime doğrulama hem TypeScript tip çıkarımı sağlar. Ayrı tip tanımları yazmaya son.
- Performans: React Hook Form'un uncontrolled yaklaşımı ve Zod v4'ün ~2x hızlanması ile form'larınız çok daha performanslı çalışır.
- Bundle size: Zod v4'ün ~%50 küçülen boyutu, özellikle mobil kullanıcılar için fark yaratır.
- Esneklik:
discriminatedUnion,refine, async validation ve genişletilebilir schema'lar ile en karmaşık form senaryolarını bile temiz bir şekilde modelleyebilirsiniz. - Test edilebilirlik: Schema'lar bağımsız birimler olarak test edilebilir; component testlerine gerek kalmaz.
Eğer hâlâ form doğrulamasını el ile useState ve if-else zincirleriyle yönetiyorsanız, bu ikiliyi denemenin tam zamanı. Bir kez geçiş yaptığınızda, geri dönmek istemeyeceksiniz.