İçeriğe geç
Sedat Demir
Geri dön

React Hook Form ve Zod v4 ile Type-Safe Form Validation Rehberi

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:

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:

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/resolvers

Not: @hookform/resolvers paketi, 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:

  1. Schema, tipin kaynağıdır. RegisterFormData tipi schema'dan türetilir; ayrı bir interface tanımlamaya gerek yoktur.
  2. refine ile cross-field validation yapılabilir. Şifre eşleşme kontrolü gibi birden fazla alana bağlı doğrulamalar schema seviyesinde ele alınır.
  3. register fonksiyonu 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:

  1. Schema'ları component dışında tanımlayın. Her render'da yeni schema oluşturmak gereksiz yük yaratır.
  2. mode: 'onBlur' veya mode: 'onSubmit' tercih edin. onChange modu her tuş vuruşunda validation tetikler ve büyük schema'larda performansı etkileyebilir.
  3. useWatch yerine watch kullanırken dikkatli olun. useWatch yalnızca izlenen alanlar değiştiğinde re-render tetikler.
  4. Zod v4'ün küçük bundle size'ından yararlanın. v3'ten v4'e geçiş, yaklaşık %50 bundle size azalması sağlar.
  5. 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:

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.


Share this post on:

Sonraki Yazı
Biome ile ESLint ve Prettier'ı Tek Araçla Değiştirin: Hız, Sadelik ve Performans