shadcn/ui ile React Hook Form ve Zod kullanmanın güncel yolu, alanları <Controller /> ile React Hook Form durumuna bağlamak ve görünümü <Field /> bileşenleriyle kurmaktır. Doğrulama şeması Zod ile yazılır, zodResolver aracılığıyla useForm kancasına verilir; her alanın fieldState bilgisi de data-invalid, aria-invalid ve FieldError üzerinden ekrana yansıtılır.

Bu yazıda resmî React Hook Form rehberini takip ederek bir hata bildirimi formu kuruyoruz, doğrulama modlarını, farklı kontrol türlerini (Input, Textarea, Select, Checkbox, RadioGroup, Switch), formu sıfırlamayı ve useFieldArray ile dinamik alanları anlatıyoruz. Son bölümde eski Form bileşenini ve neden artık önerilmediğini ele alıyoruz. Diğer form yaklaşımları için shadcn/ui öğrenme rehberi sayfasındaki Formlar bölümüne bakabilirsiniz.

Yaklaşım ve gerekli parçalar

Yaklaşım ve gerekli parçalar

Belge yaklaşımı dört maddeyle özetler: form durumu useForm ile yönetilir, kontrollü girdiler için Controller kullanılır, erişilebilir düzen Field bileşenleriyle kurulur ve istemci tarafı doğrulama Zod ile zodResolver üzerinden yapılır. Belgenin vurguladığı nokta, Field kullanımının işaretleme (markup) ve stil üzerinde tam esneklik vermesidir. Field bileşeninin kendisi Field yazısında ayrıntılı anlatılıyor.

Bileşen tarafında genellikle şunlara ihtiyaç duyarsınız: Field, Input, Textarea ve gönder düğmesi için Button. Resmî demo ayrıca açıklama alanında karakter sayacı için Input Group ve sonucu göstermek için Sonner kullanır. Paket tarafında rehber react-hook-form, @hookform/resolvers ve zod kodlarını import eder; eski Form sayfasının elle kurulum adımı bu üç paketi şu komutla birlikte listeler:

bash
npx shadcn@latest add field input textarea button
npm install @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform/resolvers zod

İkinci satır eski Form bileşeninin elle kurulum komutudur; yalnızca Field yaklaşımını kullanıyorsanız Radix paketlerine bu form için ayrıca ihtiyacınız olmayabilir, çünkü Field zaten Label bileşenini kendi kurulumuyla getirir. React 19 ile npm kullanırken peer dependency uyarısı alırsanız React 19 yazısına bakın; belgenin uyumluluk tablosunda react-hook-form sorunsuz olarak işaretlidir.

1. Adım: Zod şemasını yazmak

Formun biçimi önce bir Zod şemasıyla tanımlanır. Belge, örneklerin zod v3 ile yazıldığını ve React Hook Form tarafından desteklenen başka bir Standard Schema kütüphanesiyle değiştirilebileceğini not eder. Mesajları Türkçeleştirerek aynı şemayı yazalım:

typescript
import * as z from "zod"

const formSchema = z.object({
  title: z
    .string()
    .min(5, "Başlık en az 5 karakter olmalı.")
    .max(32, "Başlık en fazla 32 karakter olabilir."),
  description: z
    .string()
    .min(20, "Açıklama en az 20 karakter olmalı.")
    .max(100, "Açıklama en fazla 100 karakter olabilir."),
})

Şemadaki her kuralın ikinci argümanı, kural bozulduğunda alana düşecek mesajdır. Bu mesaj daha sonra fieldState.error içinde gelir ve FieldError tarafından çizilir. Yani Türkçe hata metinlerini bileşende değil, şemada yazarsınız.

2. Adım: useForm ile form örneği

typescript
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"

export function HataBildirimFormu() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      description: "",
    },
  })

  function onSubmit(data: z.infer<typeof formSchema>) {
    // Doğrulanmış değerlerle istediğinizi yapın.
    console.log(data)
  }

  return (
    <form id="hata-formu" onSubmit={form.handleSubmit(onSubmit)}>
      {/* alanlar burada */}
    </form>
  )
}

z.infer<typeof formSchema> ifadesi şemadan bir TypeScript türü çıkarır; böylece onSubmit içindeki data nesnesi tür güvenlidir. Bu tür çıkarımı bir TypeScript konusudur, ayrıntı için TypeScript rehberine bakabilirsiniz. defaultValues vermek önemlidir: eski Form sayfası, kontrollü bileşenler kullanıldığı için alanlara varsayılan değer verilmesi gerektiğini açıkça söyler. Boş metin alanları için "", onay kutusu dizileri için [], Switch için false gibi değerler verin. form.handleSubmit(onSubmit) önce doğrulamayı çalıştırır; geçerli veride onSubmit çağrılır, geçersiz veride hatalar alanların yanında görünür.

3. Adım: Controller ve Field ile alanı çizmek

Belgenin anatomi örneği her alanın nasıl kurulacağını gösterir. Controller bileşenine alan adı (name) ve form.control verilir; render fonksiyonu field ve fieldState nesnelerini alır.

typescript
<Controller
  name="title"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor={field.name}>Hata başlığı</FieldLabel>
      <Input
        {...field}
        id={field.name}
        aria-invalid={fieldState.invalid}
        placeholder="Mobilde giriş düğmesi çalışmıyor"
        autoComplete="off"
      />
      <FieldDescription>Hatayı kısa bir başlıkla özetleyin.</FieldDescription>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

Burada üç bağlantı vardır. {...field} yayılımı Input bileşenine değer, değişiklik, odak kaybı ve ref bağlantılarını verir; metin tabanlı kontrollerde yapılacak tek şey budur. fieldState.invalid değeri hem Field üzerindeki data-invalid özelliğine hem de Input üzerindeki aria-invalid özelliğine gider; biri görünümü, diğeri erişilebilirliği sağlar. Son olarak hata varsa FieldError çizilir ve tek bir hata nesnesi dizi içinde verilir: errors={[fieldState.error]}.

Resmî demoda açıklama alanı bir InputGroupTextarea ile yazılır ve altında {field.value.length}/100 characters biçiminde bir sayaç gösterilir. Sayaç doğrudan field.value okuduğu için her tuş vuruşunda güncellenir. Input Group parçaları Input ve Input Group yazısında anlatılıyor. Demoda gönder düğmesi formun dışında, kartın alt kısmındadır; bu yüzden düğmeye form="form-rhf-demo" verilerek forma bağlanır. Bu, HTML form özniteliğinin standart kullanımıdır.

Doğrulama modları

Doğrulama modları: "onChange", "onBlur", "onSubmit", "onTouched"

React Hook Form, doğrulamanın ne zaman çalışacağını mode seçeneğiyle belirler. Belgedeki tablo beş değeri listeler:

modeDoğrulama ne zaman çalışır
"onChange"Her değişiklikte
"onBlur"Alan odağı kaybettiğinde
"onSubmit"Gönderimde (varsayılan)
"onTouched"İlk odak kaybında, sonra her değişiklikte
"all"Hem odak kaybında hem değişiklikte
typescript
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})

Varsayılan onSubmit olduğu için kullanıcı formu ilk kez gönderene kadar hata görmez. onTouched ise bir ara yol sunar: kullanıcı bir alanı terk etmeden hata görmez ama düzeltirken anlık geri bildirim alır. Hangisini seçeceğiniz ürün kararıdır; belge bir öneri sunmaz.

Farklı kontrol türleri

Metin alanlarında {...field} yayılımı yeterlidir. Radix tabanlı kontrollerde ise olay adları farklı olduğu için değer ve değişiklik fonksiyonu elle bağlanır. Belgedeki kurallar şöyle özetlenebilir:

KontrolDeğer bağlantısıaria-invalid nereye
Input, Textarea{...field} yayılımıInput / Textarea
Selectvalue={field.value}, onValueChange={field.onChange}SelectTrigger
Checkbox dizisichecked ve onCheckedChange içinde dizi işlemiCheckbox
RadioGroupvalue={field.value}, onValueChange={field.onChange}RadioGroupItem
Switchchecked={field.value}, onCheckedChange={field.onChange}Switch

Select için aria-invalid kök bileşene değil, görünen düğme olan SelectTrigger üzerine konur. Belgedeki Select örneği ayrıca orientation="responsive" kullanır ve hata mesajını FieldContent içine yerleştirir:

typescript
<Controller
  name="language"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field orientation="responsive" data-invalid={fieldState.invalid}>
      <FieldContent>
        <FieldLabel htmlFor="dil-secimi">Konuştuğunuz dil</FieldLabel>
        <FieldDescription>En iyi sonuç için konuştuğunuz dili seçin.</FieldDescription>
        {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
      </FieldContent>
      <Select name={field.name} value={field.value} onValueChange={field.onChange}>
        <SelectTrigger id="dil-secimi" aria-invalid={fieldState.invalid} className="min-w-[120px]">
          <SelectValue placeholder="Seçin" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Otomatik</SelectItem>
          <SelectItem value="tr">Türkçe</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  )}
/>

Onay kutusu dizisinde tek bir alan birden fazla kutuyu temsil eder. Her kutu field.value.includes(task.id) ile işaretli olup olmadığını öğrenir; onCheckedChange içinde işaretlenirse kimlik diziye eklenir, kaldırılırsa filter() ile çıkarılır ve yeni dizi field.onChange(newValue) ile forma yazılır. Belge, bu listeyi saran FieldGroup üzerine data-slot="checkbox-group" eklemeyi hatırlatır.

typescript
<Checkbox
  id={`gorev-${task.id}`}
  name={field.name}
  aria-invalid={fieldState.invalid}
  checked={field.value.includes(task.id)}
  onCheckedChange={(checked) => {
    const newValue = checked
      ? [...field.value, task.id]
      : field.value.filter((value) => value !== task.id)
    field.onChange(newValue)
  }}
/>

RadioGroup örneğinde her seçenek bir seçim kartı olarak yazılır: FieldLabel içine yatay bir Field, onun içine FieldContent ve RadioGroupItem konur. Switch örneğinde ise checked={field.value} ve onCheckedChange={field.onChange} yeterlidir; alanın varsayılan değeri false olmalıdır. Bu kontrollerin kendileri seçim kontrolleri yazısında ve Select yazısında anlatılıyor; aranabilir seçim için Combobox yazısına, doğrulama kodu girişi için Input OTP yazısına bakın.

Resmî demo nasıl kurulmuş?

Belgenin React Hook Form demosu bir hata bildirimi kartıdır ve yapısı başka formlar için de şablon olarak kullanılabilir. Card bileşeninin başlığında form adı ve kısa açıklama, gövdesinde id="form-rhf-demo" taşıyan form, altbilgisinde ise yatay bir Field içinde Sıfırla ve Gönder düğmeleri bulunur. Form içindeki iki Controller bir FieldGroup ile sarılır, böylece alanlar arasındaki boşluk Field tarafından belirlenir.

Gönderimde onSubmit fonksiyonu Sonner toast() çağırır ve doğrulanmış veriyi JSON.stringify(data, null, 2) ile bir pre bloğunda gösterir. Toast çağrısına ayrıca position: "bottom-right" ve özel sınıflar verilir. Bu kısım yalnızca gösterim içindir; gerçek uygulamada bu fonksiyonda verinizi bir API ya da sunucu eylemine gönderirsiniz. Resmî örnek galerisinde ayrıca giriş, metin alanı, Select, onay kutusu, radyo, Switch, parola ve dizi için ayrı örnekler ile bunların hepsini birleştiren karmaşık bir form bulunur; hepsi aynı Controller ve Field kalıbını tekrarlar.

Formu sıfırlamak

Belge, formu varsayılan değerlerine döndürmek için form.reset() kullanılmasını söyler. Düğmenin type="button" olması önemlidir; aksi halde düğme formun içindeyse gönderimi tetikler.

typescript
<Button type="button" variant="outline" onClick={() => form.reset()}>
  Sıfırla
</Button>

Dizi alanları: useFieldArray

Kullanıcının alan ekleyip çıkarabildiği listeler (örneğin birden fazla e-posta adresi) için React Hook Form useFieldArray kancasını sağlar. Kanca fields, append ve remove döndürür. Belgenin en önemli uyarısı anahtarla ilgilidir: her öğe için key olarak field.id kullanılmalıdır, dizi indeksi değil.

typescript
const { fields, append, remove } = useFieldArray({
  control: form.control,
  name: "emails",
})

{fields.map((field, index) => (
  <Controller
    key={field.id}
    name={`emails.${index}.address`}
    control={form.control}
    render={({ field: controllerField, fieldState }) => (
      <Field orientation="horizontal" data-invalid={fieldState.invalid}>
        <FieldContent>
          <InputGroup>
            <InputGroupInput
              {...controllerField}
              id={`eposta-${index}`}
              aria-invalid={fieldState.invalid}
              placeholder="ad@ornek.com"
              type="email"
              autoComplete="email"
            />
            {fields.length > 1 && (
              <InputGroupAddon align="inline-end">
                <InputGroupButton
                  type="button"
                  variant="ghost"
                  size="icon-xs"
                  onClick={() => remove(index)}
                  aria-label={`E-posta ${index + 1} kaldır`}
                >
                  <XIcon />
                </InputGroupButton>
              </InputGroupAddon>
            )}
          </InputGroup>
          {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
        </FieldContent>
      </Field>
    )}
  />
))}

Alan adı nokta gösterimiyle yazılır: emails.${index}.address. render içindeki parametre controllerField olarak yeniden adlandırılır, çünkü dış döngüdeki field adı zaten kullanılmaktadır. Yeni öğe eklemek için append({ address: "" }) çağrılır ve resmî örnekte düğme fields.length >= 5 olduğunda devre dışı kalır. Kaldırma düğmesi yalnızca birden fazla öğe varken gösterilir. Doğrulama tarafında Zod array() ile en az ve en fazla öğe sayısı tanımlanır:

typescript
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("Geçerli bir e-posta adresi girin."),
      })
    )
    .min(1, "En az bir e-posta adresi ekleyin.")
    .max(5, "En fazla 5 e-posta adresi ekleyebilirsiniz."),
})

Eski Form bileşeni: FormField, FormItem, FormControl

Eski Form bileşeni: FormField, FormItem, FormControl: Bağımlılık, Kimlik bağlantısı, Hata gösterimi, Geliştirme durumu

shadcn/ui deposunda npx shadcn@latest add form ile eklenen eski bir Form bileşeni de bulunur. Belgenin Form sayfası en üstte, bu bileşenin artık aktif olarak geliştirilmediğini ve form kurmak için Field bileşeninin önerildiğini belirtir. Yine de mevcut projelerde, eski blog yazılarında ve bazı resmî örneklerde (örneğin Input OTP form örneği) bu yapıyla karşılaşırsınız:

typescript
<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
    <FormField
      control={form.control}
      name="username"
      render={({ field }) => (
        <FormItem>
          <FormLabel>Kullanıcı adı</FormLabel>
          <FormControl>
            <Input placeholder="shadcn" {...field} />
          </FormControl>
          <FormDescription>Herkese görünen adınızdır.</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
    <Button type="submit">Gönder</Button>
  </form>
</Form>

Kaynak koda bakınca farkın nereden geldiği anlaşılır. Form aslında React Hook Form FormProvider bileşenidir. FormField, Controller bileşenini bir bağlamla sarar. FormItem React.useId() ile benzersiz bir kimlik üretir; FormLabel bu kimliği htmlFor olarak, FormControl ise içindeki kontrole id, aria-describedby ve aria-invalid olarak Radix Slot üzerinden geçirir. FormMessage hata varsa mesajı, yoksa çocuklarını gösterir. Yani eski bileşen kimlik ve ARIA bağlantılarını otomatik yapar; Field yaklaşımında ise bunları açıkça kendiniz yazarsınız.

KonuEski FormField + Controller
BağımlılıkReact Hook Form bağlamı zorunluForm kütüphanesinden bağımsız
Kimlik bağlantısıuseId ile otomatikhtmlFor ve id elle
Hata gösterimiFormMessageFieldError ve errors dizisi
Geliştirme durumuAktif geliştirilmiyorBelgenin önerdiği yol
Düzen seçenekleriFormItem içinde serbestorientation, FieldSet, FieldGroup

Sık yapılan hatalar

  • defaultValues vermemek: kontrollü bileşenler tanımsız değerle başlar; boş metin, boş dizi ya da false verin.
  • Select üzerinde {...field} yaymak: Radix Select onChange değil onValueChange kullanır; belgedeki gibi value ve onValueChange bağlayın.
  • aria-invalid değerini Select kökünde aramak: belge bunu SelectTrigger üzerine koyar.
  • FieldError için tek nesne vermek: errors bir dizi bekler, [fieldState.error] yazın.
  • useFieldArray döngüsünde indeksi anahtar yapmak: belge field.id kullanılmasını ister.
  • Sıfırla düğmesini type="button" olmadan yazmak: form içindeki düğme varsayılan olarak gönderim yapar.
  • Bileşen dosyasında "use client" unutmak: useForm bir kancadır, Next.js App Router içinde istemci bileşeni gerekir.

Sık Sorulan Sorular

Zod yerine başka bir doğrulama kütüphanesi kullanabilir miyim?

Belge, örneklerin zod v3 ile yazıldığını ve React Hook Form tarafından desteklenen başka bir Standard Schema kütüphanesiyle değiştirilebileceğini söyler. FieldError de Standard Schema hata kayıtlarını kabul eder.

Sunucu tarafında da doğrulama yapmalı mıyım?

Bu rehber yalnızca istemci tarafı doğrulamayı anlatır. Sunucuda doğrulama ve Next.js Server Actions ile form kurma Server Actions yazısında ele alınıyor.

Gönderimden sonra değerleri nasıl gösteriyorlar?

Resmî demolar onSubmit içinde Sonner toast() çağırır ve değerleri JSON.stringify(data, null, 2) ile bir kod bloğunda gösterir. Sonner kullanımı Sonner yazısında.

TanStack Form ile arasındaki fark nedir?

İki rehber aynı Field bileşenlerini kullanır; fark durum yönetimindedir. TanStack Form tarafı TanStack Form yazısında karşılaştırmalı anlatılıyor.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: shadcn/ui öğrenme rehberi · Field bileşeni · TanStack Form · Next.js Server Actions formu · Input Group · Sonner bildirimleri