React'te Form'lar: Controlled Input, Form Action'ları, useActionState, useFormStatus ve react-hook-form + zod
Küçük formlar için React 19'un form action'ları (useActionState, useFormStatus, useOptimistic) yeterli; kalemli ve iç içe formlarda react-hook-form ile zod şeması daha az kod ve daha az render demek. Bookstore'un sipariş formunda controlled sürümün her tuşta render ettiğini, action'ın doğrulama hatasından sonra formu sıfırladığını, useOptimistic'in hata olunca kendiliğinden geri döndüğünü ve Problem Details hatalarının alanlara eşlenip odağın taşındığını gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler React 19.3, react-hook-form 7.92, zod 4.6, @hookform/resolvers 7.0, React Router 8.5, TanStack Query 5.107, TypeScript 7.0, Vite 8.3, Oxlint 1.93 ve Node.js 24 ile denenmiştir; API çağrıları Bookstore.Api'nin (ASP.NET Core 10) yerel bir kopyasına Vite dev proxy'si üzerinden yapılmış, form davranışları Playwright 1.65 ile headless Chromium'da gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React istemcisinde ödeme sayfası bir önceki yazıda tek bir butondu. Bu yazıda gerçek bir sipariş formu kuruyoruz: ad, e-posta, teslimat adresi ve sepetteki her kitap için düzenlenebilir bir adet alanı.
Formun ilk sürümü React 19'un form action'larıyla yazılmış. Kullanıcı adresi doldurup posta koduna dört hane yazıyor ve "Place order"a basıyor. Action doğrulama hatasını döndürüyor ve "Postal codes have 5 digits" mesajı görünüyor. Kullanıcının yazdığı sokak ve şehir alanlarında ne kalır? İkinci soru: her alanı useState ile tutan bir formda sokak alanına 11 karakter yazmak component'i kaç kez render eder, react-hook-form ile kaç kez?
Elle Controlled Form ve Nerede Zorlaşıyor
Beşinci yazıda arama kutusunu controlled input olarak yazmıştık: değer state'te, input yalnız gösteriyor. Aynı yaklaşımla bir sipariş formu:
// Alternatif sürüm: her alan controlled state, doğrulama elle yazılmış
export function CheckoutPage() {
const { user } = useAuth();
const { total } = useCart();
const [name, setName] = useState(user?.name ?? '');
const [email, setEmail] = useState(user?.email ?? '');
const [line1, setLine1] = useState('');
const [city, setCity] = useState('');
const [postalCode, setPostalCode] = useState('');
const [errors, setErrors] = useState<Errors>({});
function validate(): Errors {
const next: Errors = {};
if (!name.trim()) next.name = 'Enter your name';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) next.email = 'Enter a valid email address';
if (!line1.trim()) next.line1 = 'Enter the street and number';
if (!city.trim()) next.city = 'Enter the city';
if (!/^\d{5}$/.test(postalCode)) next.postalCode = 'Postal codes have 5 digits';
return next;
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const next = validate();
setErrors(next);
// ...bir de sipariş kalemleri, sunucu hataları, ilk hataya odak, pending ve dirty bilgisi...
}
Beş alan için beş state, bir hata state'i, elle yazılmış doğrulama. Daha eksik çok şey var: kalem listesi, sunucu hataları, ilk hatalı alana odak, gönderim sürerken butonu kilitlemek, "kullanıcı bu alana dokundu mu" bilgisi. Her biri birkaç satır daha state ve koşul demek. Maliyet yalnız kod miktarı değil. Production build'de iki sürümü, sokak alanına 11 karakter yazdırarak ölçtük:
controlled (useState): typing "Moda Cd. 12" (11 keys): CheckoutPage rendered 11 time(s)
controlled (useState): submit with errors: CheckoutPage rendered 1 time(s)
react-hook-form: typing "Moda Cd. 12" (11 keys): CheckoutPage rendered 0 time(s)
react-hook-form: submit with errors: CheckoutPage rendered 2 time(s)
Controlled formda her tuş vuruşu bütün form component'ini render etti. Beş alanlık bir formda bu hissedilmez; yüzlerce satırlık bir sipariş kalemi tablosunda ya da her alanın altında pahalı bir önizleme olan bir formda hissedilir. react-hook-form yazarken hiç render etmedi: input'lar uncontrolled, değerler DOM'da (tarayıcının sayfayı tuttuğu node ağacı) duruyor ve kütüphane onları ref üzerinden okuyor. Render yalnız gönderimde, hata durumu değişince oldu.
React 19 Form Action'ları: useActionState ve useFormStatus
React 19, <form>'un action prop'una bir fonksiyon verilmesine izin veriyor. Form gönderilince React bu fonksiyonu bir transition içinde, form verisiyle çağırıyor. useActionState bu fonksiyona bir önceki state'i de veriyor ve döndürdüğü değeri yeni state yapıyor:
// Alternatif sürüm: React 19 form action'ları, form kütüphanesi yok
const contactSchema = z.object({
name: z.string().trim().min(1, { error: 'Enter your name' }),
email: z.email({ error: 'Enter a valid email address' }),
line1: z.string().trim().min(1, { error: 'Enter the street and number' }),
city: z.string().trim().min(1, { error: 'Enter the city' }),
postalCode: z.string().regex(/^\d{5}$/, { error: 'Postal codes have 5 digits' }),
});
type FormState = {
values: Record<string, string>;
errors: Partial<Record<string, string[]>>;
message?: string;
// Action önceki state'i ve gönderilen FormData'yı alır; döndürdüğü değer yeni state olur
async function submitOrder(_previous: FormState, formData: FormData): Promise<FormState> {
const values = Object.fromEntries(formData) as Record<string, string>;
const parsed = contactSchema.safeParse(values);
if (!parsed.success) {
return { values, errors: z.flattenError(parsed.error).fieldErrors };
}
try {
const order = await cartApi.placeOrder(cartId!, parsed.data.name, parsed.data.email);
cartIdStore.set(null);
queryClient.removeQueries({ queryKey: ['cart'] });
navigate(`/orders/${order.id}`);
return { values, errors: {} };
} catch (error) {
return { values, errors: {}, message: error instanceof Error ? error.message : String(error) };
}
}
const [state, formAction] = useActionState(submitOrder, {
values: { name: user?.name ?? '', email: user?.email ?? '' },
errors: {},
});
// Action'dan sonra React formun uncontrolled input'larını sıfırlar; defaultValue gönderilen metni geri koyar
const field = (name: string, label: string) => (
<p className="field">
<label htmlFor={name}>{label}</label>
<input id={name} name={name} defaultValue={state.values[name] ?? ''} aria-invalid={!!state.errors[name]} />
{state.errors[name] && <span className="error">{state.errors[name].join(' ')}</span>}
</p>
);
// useFormStatus içinde render edildiği <form>'un durumunu okur: bir alt component olmalı
function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? 'Placing order…' : 'Place order'}</button>;
}
Input'larda value ya da onChange yok; değerler FormData'dan okunuyor. Doğrulama zod şemasıyla action'ın içinde yapılıyor, hatalar state'e yazılıyor. useFormStatus içinde render edildiği <form>'un gönderim durumunu okuyor; bu yüzden butonu ayrı bir component'e almak zorundayız, aynı component'teki form'u göremiyor. Gönderimde POST /api/orders'ı bir saniye geciktirdiğimiz koşunun ilk ve son satırları:
-- submit with postal code "3400"
inputs: {"Street and number":"Moda Cd. 12","City":"Istanbul","Postal code":"3400"}
error: Postal codes have 5 digits
-- remove Emma (DELETE takes 1.5 s)
at once: ["1 × Dune"]
saved: ["1 × Dune"]
-- remove Dune, the server answers 500
at once: []
after: ["1 × Dune"]
-- postal code "34710", submit (POST takes 1 s)
button: Placing order… | disabled: true
page: /orders/1 | Order #1
Önce Tahmin Et'teki ilk sorunun cevabı burada ilk bloğa bakınca "her şey yerinde" gibi görünüyor, ama bu defaultValue={state.values[name]} satırı sayesinde. Aynı formu o satır olmadan çalıştırdık:
-- submit with postal code "3400"
inputs: {"Street and number":"","City":"","Postal code":""}
error: Postal codes have 5 digits
Sokak, şehir ve posta kodu boşaldı; kullanıcı her şeyi yeniden yazmak zorunda. react.dev'e göre action'a verilen fonksiyon hata fırlatmadan bittiğinde React formun uncontrolled alanlarını sıfırlıyor. Doğrulama hatası döndürmek de "hata fırlatmadan bitmek" sayılıyor. Belgelenen çözüm, gönderilen değerleri state'te saklayıp defaultValue'ya geri vermek; React sıfırlamadan önce yeni default'ları uyguluyor. Başarılı bir kayıttan sonra formu temizlemek için faydalı bu davranış, doğrulama hatalarında ilk karşılaşıldığında şaşırtıyor.
useOptimistic ile Anında Geri Bildirim
Aynı formda her kalemin yanında bir "Remove" butonu var. Silme bir DELETE isteği; sonucu beklemeden satırı gizlemek için useOptimistic:
// DELETE bitene kadar satırları silinmekte olan satır olmadan gösterir
const [visibleLines, hideLine] = useOptimistic(lines, (current, isbn: string) => current.filter((l) => l.isbn !== isbn));
function removeLine(isbn: string) {
startTransition(async () => {
hideLine(isbn);
try {
const saved = await cartApi.remove(cartId!, isbn);
queryClient.setQueryData(cartKey(saved.id), saved);
} catch {
// geri alınacak bir şey yok: transition bitince optimistic değer bırakılır ve satır geri gelir
}
});
}
Yukarıdaki çıktının ortası bu kısmın sonucu: DELETE 1,5 saniye sürerken Emma anında listeden çıktı. Dune'u silerken sunucu 500 döndü; satır 100 ms'de kayboldu, transition bitince geri geldi. Geri alma kodu yazmadık: useOptimistic'in değeri yalnız bir transition sürerken geçerli, transition bitince React gerçek değere (lines) dönüyor. Sekizinci yazıdaki TanStack Query optimistic update'iyle fark bu: orada cache'i biz değiştirip hata durumunda eski hâline biz yazıyorduk.
Bu yaklaşımın kazancı bağımlılıksız olması ve React'in transition modeline doğrudan bağlanması. Server Function'larla (on beşinci yazı) aynı API'yi kullanıyor; framework'lerde JavaScript yüklenmeden de çalışabilen formlar bu API ile yazılıyor. Sınırı da belli: alan bazlı anlık doğrulama, "dokunuldu" bilgisi, iç içe object'ler ve kalem listeleri için yine her şeyi elle yazıyorsun.
react-hook-form ve zod: Şema, Kayıt ve Field Array
npm install react-hook-form@7.92.0 zod@4.6.7 @hookform/resolvers@7.0.6
Önce şema. zod ile yazılan şema hem form değerlerinin tipini, hem doğrulama kurallarını, hem de hata mesajlarını tek yerde tutuyor:
import * as z from 'zod';
// Tek şema: formun tipleri, doğrulama kuralları ve hata mesajları
export const checkoutSchema = z.object({
name: z.string().trim().min(1, { error: 'Enter your name' }).max(200),
email: z.email({ error: 'Enter a valid email address' }),
address: z.object({
line1: z.string().trim().min(1, { error: 'Enter the street and number' }),
city: z.string().trim().min(1, { error: 'Enter the city' }),
postalCode: z.string().regex(/^\d{5}$/, { error: 'Postal codes have 5 digits' }),
}),
lines: z
.array(z.object({
isbn: z.string(),
title: z.string(),
unitPrice: z.number(),
quantity: z.number({ error: 'Enter a number' }).int().min(1, { error: 'At least 1' }).max(99, { error: 'At most 99' }),
}))
.min(1, { error: 'Your order has no books' }),
});
export type CheckoutValues = z.infer<typeof checkoutSchema>;
z.infer ile türetilen CheckoutValues elle yazılmış bir interface değil; şema değişince tip de değişiyor. zod 4'te hata mesajı { error: '...' } parametresiyle veriliyor ve z.email() artık üst seviye bir fonksiyon. Formun kurulumu:
export function CheckoutPage() {
const { user } = useAuth();
const { cartId, lines } = useCart();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { register, control, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<CheckoutValues>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
address: { line1: '', city: '', postalCode: '' },
lines: lines.map(({ isbn, title, unitPrice, quantity }) => ({ isbn, title, unitPrice, quantity })),
},
});
// FormArray gibi: her sipariş kalemi için bir satır, her birinin key için sabit bir id'si var
const { fields, remove } = useFieldArray({ control, name: 'lines' });
// useWatch yalnız satırlara abone olur; watch() React Compiler'ın bu component'i atlamasına yol açardı
const total = useWatch({ control, name: 'lines' }).reduce((sum, line) => sum + line.unitPrice * (line.quantity || 0), 0);
register('address.city') input'a name, ref, onChange ve onBlur veriyor; nokta ile ayrılmış path iç içe object'e karşılık geliyor. useFieldArray, Angular'daki FormArray'in karşılığı: her satıra bir id üretiyor ve key olarak index yerine bu kullanılıyor; react-hook-form dokümanı index'i key yapmanın satırları bozacağını açıkça yazıyor (üçüncü yazıdaki index key tuzağının aynısı). Toplamı göstermek için watch() yerine useWatch kullandık; sebebi ilk sürümdeki Oxlint uyarısı:
> bookstore-react@0.0.0 lint
> oxlint
src/features/checkout/CheckoutPage.tsx:32:17: warning react(incompatible-library): Use of incompatible library help: This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized
watch() her çağrıda yeni değer döndüren bir fonksiyon; React Compiler bu component'i memoize etmekten vazgeçer. On ikinci yazıda compiler'ı açacağımız için bu uyarıyı şimdiden temizledik. Ödeme route'u artık bir loader'la geliyor; form ilk render'da sepet satırlarını default değer olarak almak zorunda:
import { redirect } from 'react-router';
import { cartQuery } from '@/features/cart/cartApi';
import { cartIdStore } from '@/features/cart/cartIdStore';
import { queryClient } from '@/shared/queryClient';
// Form sepet satırlarını default değer olarak istiyor; bu yüzden render'dan önce yüklenmeleri gerekir
export async function checkoutLoader() {
const cartId = cartIdStore.get();
const cart = cartId === null ? null : await queryClient.ensureQueryData(cartQuery(cartId));
if (cart === null || cart.items.length === 0) {
throw redirect('/cart');
}
return null;
}
Gönderim handleSubmit ile; fonksiyon ancak şema geçerliyse çağrılıyor:
async function placeOrder(values: CheckoutValues) {
try {
// Sunucudaki sepeti düzenlenen satırlarla eşitle, sonra siparişe çevir
for (const line of lines) {
const edited = values.lines.find((l) => l.isbn === line.isbn);
const saved = !edited ? await cartApi.remove(cartId!, line.isbn)
: edited.quantity !== line.quantity ? await cartApi.setQuantity(cartId!, line.isbn, edited.quantity)
: null;
if (saved) {
queryClient.setQueryData(cartKey(saved.id), saved);
}
}
const order = await cartApi.placeOrder(cartId!, values.name, values.email);
queryClient.removeQueries({ queryKey: ['cart'] });
queryClient.invalidateQueries({ queryKey: ['book'] });
cartIdStore.set(null);
navigate(`/orders/${order.id}`);
} catch (error) {
if (error instanceof ApiError && error.problem) {
applyServerErrors(error.problem, setError);
} else {
setError('root.server', { message: error instanceof Error ? error.message : String(error) });
}
}
}
Bir önceki yazıdaki router action'ını kaldırdık. react-hook-form'u useSubmit ile bir action'a bağlamak mümkün, ama doğrulama ve hata eşleme zaten formda olduğu için gönderimi de formda tuttuk; route'ta yalnız middleware (giriş kontrolü) ve loader (sepet) kaldı. Adres alanları için bir not: yerel API kopyasındaki PlaceOrderRequest yalnız sepet, ad ve e-posta alıyor; teslimat Shipping module'ünün işi. Adres burada doğrulanıyor ama isteğe eklenmiyor. Akışın tamamı:
http: PUT /api/carts/{cartId}/items/9780441172719 200
http: PUT /api/carts/{cartId}/items/9781400079988 200
-- submit with an empty address
error: Street and number: Enter the street and number
error: City: Enter the city
error: Postal code: Postal codes have 5 digits
focus: Street and number
-- fill the address, postal code "3400"
error: Postal code: Postal codes have 5 digits
focus: Postal code
-- postal code "34710", War and Peace quantity 5 (3 in stock)
total: Total: ₺3,934.00
http: PUT /api/carts/{cartId}/items/9781400079988 200
http: POST /api/orders 409
error: form: Not enough stock for: 9781400079988.
focus: the form error message
-- quantity 3, submit again
http: PUT /api/carts/{cartId}/items/9781400079988 200
http: POST /api/orders 201
http: GET /api/orders/1 200
page: /orders/1 | Order #1 | Status: Placed | 1 × Dune · ₺489.00 | 3 × War and Peace · ₺2,067.00 | Total: ₺2,556.00
Boş adresle gönderimde üç hata birden çıktı ve odak ilk hatalı alana gitti; bunu react-hook-form register'ın verdiği ref ile kendisi yapıyor. Posta kodu düzeltilip War and Peace'in adedi 5 yapılınca önce sunucudaki sepet güncellendi, sipariş 409 ile döndü ve form seviyesinde bir hata göründü. Adet 3'e inince sipariş oluştu.
Sunucu Hatalarını Form Alanlarına Eşlemek
İstemci doğrulaması kullanıcı deneyimi içindir; asıl kural sunucudadır ve ikisi her zaman aynı değildir. Bookstore.Api doğrulama hatalarını Problem Details'ın errors alanında, request'in property adlarıyla döndürüyor. Bunları formun alan adlarına çeviren küçük bir fonksiyon:
import type { FieldPath, UseFormSetError } from 'react-hook-form';
import type { CheckoutValues } from '@/features/checkout/checkoutSchema';
import type { ProblemDetails } from '@/shared/api';
// Bookstore.Api hataları request property adıyla bildirir (PlaceOrderRequest); form kendi adlarını kullanır
const fieldFor: Record<string, FieldPath<CheckoutValues>> = {
CustomerName: 'name',
CustomerEmail: 'email',
};
export function applyServerErrors(problem: ProblemDetails, setError: UseFormSetError<CheckoutValues>) {
const unmatched: string[] = [];
for (const [key, messages] of Object.entries(problem.errors ?? {})) {
const field = fieldFor[key];
if (field) {
setError(field, { type: 'server', message: messages.join(' ') }, { shouldFocus: true });
} else {
unmatched.push(...messages);
}
}
// Kendi alanı olmayan her şey (409 stok hatası, bilinmeyen anahtarlar) form seviyesindeki hataya gider
const formMessage = unmatched.length > 0 ? unmatched.join(' ') : problem.errors ? '' : (problem.detail ?? problem.title ?? '');
if (formMessage) {
setError('root.server', { type: 'server', message: formMessage });
}
}
Sunucunun kurallarını görmek için zod resolver'ını geçici olarak kapattık ve e-posta alanına ada@ yazdık:
const { register, control, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<CheckoutValues>({
// resolver: zodResolver(checkoutSchema), (sunucu kurallarını görmek için kapatıldı)
http: POST /api/orders 400
body: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerEmail":["The CustomerEmail field is not a valid e-mail address."]},"traceId":"00-13d6672a917d45abd47ff4d2b99e0080-7a0f29481f2f52da-00"}
error: Email: The CustomerEmail field is not a valid e-mail address.
aria-invalid on Email: true
focus: Email
CustomerEmail anahtarı form'daki email alanına düştü, alan aria-invalid oldu ve odak oraya gitti. Mesaj sunucunun İngilizce cümlesi; kullanıcıya göstermeden önce kendi metninle değiştirmek ya da sunucuyla bir hata kodu üzerinde anlaşmak daha iyi. Alanı olmayan hatalar (409 stok hatası, eşlenmemiş anahtarlar) root.server'a gidiyor.
Erişilebilir Hata Mesajları
Her alan küçük bir Field component'inden geçiyor:
// Odak gönder butonundaydı; klavye ve ekran okuyucu kullanıcıları fark etsin diye odağı mesaja taşı.
// Cleanup döndürmek, React 19'un bu ref'i null ile çağırmasını engeller (yedinci yazı).
function focusOnAttach(node: HTMLParagraphElement) {
node.focus();
return () => {};
}
type FieldProps = {
label: string;
error: FieldError | undefined;
children: (props: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode;
};
// Ekran okuyucular için label, input ve hata mesajı birbirine bağlanır
function Field({ label, error, children }: FieldProps) {
const id = useId();
return (
<p className="field">
<label htmlFor={id}>{label}</label>
{children({ id, 'aria-invalid': error !== undefined, 'aria-describedby': error ? `${id}-error` : undefined })}
{error && <span id={`${id}-error`} className="error">{error.message}</span>}
</p>
);
}
useId label'ı input'a, aria-describedby hata mesajını input'a bağlıyor; ekran okuyucu alana gelince hem etiketi hem hatayı okuyor. aria-invalid hem yardımcı teknolojiye hem CSS'e alanın hatalı olduğunu söylüyor. Form seviyesindeki hata için bir sorun daha çıktı: gönderim sırasında buton disabled olduğu için odak buton üzerinden body'ye düşüyordu ve klavye kullanıcısı 409 mesajını fark etmiyordu. Yedinci yazıdaki ref callback'leri burada işe yaradı: mesaj ekrana gelince focusOnAttach odağı ona taşıyor, yukarıdaki çıktıda "focus: the form error message" satırı bunun sonucu. Hata mesajlarının dili, sırası ve görünürlüğü gibi konuların ayrıntısı Accessibility (a11y) serisinde.
Angular Reactive Forms ve Signal Forms ile Kıyas
| Konu | Angular Reactive Forms | Angular Signal Forms (22) | React + react-hook-form + zod |
|---|---|---|---|
| Model | FormGroup / FormControl ağacı | bir signal model ve form() | useForm ve uncontrolled input'lar |
| Kurallar | Validators.required, custom validator | şema fonksiyonundaki kurallar | zod şeması, zodResolver |
| Template bağlantısı | formControlName | [formField] | {...register('name')} |
| Liste | FormArray | array model ve şemada array kuralları | useFieldArray |
| Tip güvenliği | typed forms (Angular 14+) | model tipinden | z.infer ile şemadan |
| Sunucu hatası | control.setErrors() | şemada async/HTTP doğrulama ya da elle | setError() |
| Kurulum | framework'ün parçası | framework'ün parçası | iki ayrı kütüphane ve bir köprü paketi |
En büyük fark son satırda. Angular'da form altyapısı framework'le geliyor ve Angular 22'de Signal Forms stable oldu; "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı" yazısında anlattık. React'te çekirdek yalnız form action'larını veriyor; büyük formlar için kütüphane seçmek senin kararın. react-hook-form ve zod yaygın tercih ama tek seçenek değil: TanStack Form, Formik ve doğrulama için Valibot aynı problemi farklı tercihlerle çözüyor.
Üretimde Dikkat
İstemci ve sunucu kurallarını senkron tut. zod şeması ile PlaceOrderRequest'teki attribute'lar iki ayrı yerde yaşıyor; biri değişip diğeri değişmezse kullanıcı ya gereksiz yere engellenir ya da formu geçip sunucudan hata alır. Sunucudan gelen errors'ı her zaman alanlara eşleyecek bir yol bırakmak bu farkı kullanıcıya düzgün gösterir. Şemayı OpenAPI dokümanından üretmek de bir seçenek.
Gönderimde ağ isteklerinin sırasını düşün. Bizim formda adet değişiklikleri teker teker PUT ile gidiyor, ardından sipariş veriliyor; aradaki bir hata sepeti yarı güncellenmiş bırakabilir. Sunucu tarafında tek bir "sepeti bu satırlarla sipariş et" endpoint'i daha sağlam olurdu; API tasarımı istemcinin işini doğrudan etkiliyor.
Formdaki kişisel veriyi (adres, e-posta) log'lara, analitik event'lerine ya da hata takip aracına gönderme. react-hook-form'un DevTool paketi ya da console.log(values) geliştirmede faydalı, production'da veri sızıntısı.
Sık Düşülen Tuzaklar
Form action'ında defaultValue'yu unutmak: yukarıda gördüğümüz gibi doğrulama hatasından sonra kullanıcının yazdıkları silinir.
useFormStatus'u formla aynı component'te çağırmak: hook yalnız içinde render edildiği formu görür; aynı component'teki <form> için her zaman "pending değil" döner.
Field array'de index'i key yapmak: satır silinince React sonraki satırın input'unu yeniden kullanır ve değerler kayar. field.id kullan; react-hook-form dokümanı v8'de bu alanın key olarak yeniden adlandırılacağını söylüyor.
Sayı alanlarını string bırakmak: <input type="number">'ın değeri yine string'dir. register(..., { valueAsNumber: true }) ya da şemada z.coerce.number() kullanmazsan zod "expected number" hatası verir.
Odak yönetimini unutmak: hata mesajını göstermek yetmez, klavye ve ekran okuyucu kullanıcısının onu fark etmesi gerekir. İlk hatalı alana ya da form seviyesindeki mesaja odak taşı.
Küçük Bir Deneme
★☆☆ Şemaya bir "Gift note" alanı ekle: isteğe bağlı, en fazla 120 karakter. Kalan karakter sayısını useWatch ile göster; yazarken kaç render olduğunu ölç.
★★☆ Fatura adresi için "Same as delivery address" checkbox'ı ekle. İşaret kaldırılınca ikinci bir adres bloğu açılsın ve yalnız o zaman doğrulansın. zod'da bunu z.discriminatedUnion ile mi, superRefine ile mi yazmak daha okunur?
★★★ Sipariş formunu React 19 action'larıyla yaz ama zod şemasını ve sunucu hatası eşlemesini koru: action, FormData'yı checkoutSchema'ya uyan bir object'e çevirsin (iç içe adres ve kalemler dahil). Hangi kısım react-hook-form'dakinden kolay, hangisi zor?
Formu kurduk; sipariş akışı uçtan uca çalışıyor. Bu süreçte state'imiz farklı yerlere dağıldı: sunucu verisi TanStack Query cache'inde, kullanıcı ve sepet id'si iki küçük dış store'da, tema bir Context'te, arama URL'de. Sıradaki yazıda bu dağılımı bir karar tablosuna oturtacak, Context'in performans sınırına Zustand'ın selector'larıyla bakacak ve Redux Toolkit'in ne zaman gerektiğini tartışacağız.