Formularze w React to pole minowe. Między useState na każde pole, ręczne preventDefault, walidacją po obu stronach, obsługą błędów, reset, integracją z UI kitami — prosty formularz kontaktowy potrafi urosnąć do 200 linii kodu. Gdy dochodzą dynamiczne pola (dodawanie/usuwanie), multi-step wizard, asynchroniczna walidacja — sytuacja robi się krytyczna.
React Hook Form zarządza formularzami w React, a Zod definiuje schemat walidacji i typy danych. to stack, który w 2026 roku traktuje formularze jak powinno się je traktować — deklaratywnie, typowo, wydajnie. W tym artykule pokazuję, jak realnie budować formularze w tym stacku od prostych po najbardziej skomplikowane, i porównuję z React 19 Actions — żebyś wiedział, kiedy który wybrać.
Dlaczego nie useState per pole
Klasyczne podejście z lat 2017–2020:
Code
function ContactForm() { const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [message, setMessage] = useState(''); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const validate = () => { const newErrors = {}; if (!email) newErrors.email = 'Email required'; if (!email.includes('@')) newErrors.email = 'Invalid email'; if (!name) newErrors.name = 'Name required'; if (!message || message.length < 10) newErrors.message = 'Message too short'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e) => { e.preventDefault(); if (!validate()) return; setIsSubmitting(true); // ... fetch call setIsSubmitting(false); }; return (/* JSX z useState'ami na każdym polu */);}
Działa, ale problemów jest cała lista:
Każda zmiana re-renderuje cały formularz. Przy 10 polach to 10 re-renderów dla każdego wpisanego znaku.
Walidacja ręczna — łatwo coś pominąć, trudno utrzymać spójność po stronie serwera.
Brak reuse — każdy formularz od nowa.
Brak wsparcia dla dynamicznych pól (dodawanie/usuwanie itemów).
React Hook Form rozwiązuje wszystko powyższe, a Zod dodaje type-safe walidację. Razem — default stack dla każdego nowego formularza.
register — spinka input ze stanem formularza, bez onChange handlerów.
Walidacja automatyczna — Zod schema → błędy.
Typy wywodzone z Zod schema — z.infer<typeof ContactSchema> daje TypeScript typ.
Minimum re-renderów — RHF pod spodem używa uncontrolled inputs z ref'ami.
isSubmitting — pending state out of the box.
Dla porównania — ten sam formularz w klasycznym podejściu to 60-80 linii.
register vs Controller
RHF ma dwa API do łączenia inputów ze stanem formularza.
register — działa z natywnymi <input>, <textarea>, <select>. Najlepsze performance, najmniej kodu.
Code
<input {...register('email')} />
Controller — wrapper dla custom components, które nie mają ref-a albo używają nietypowego API (np. date pickery, rich text editory, react-select).
Code
import { Controller } from 'react-hook-form';import DatePicker from 'react-datepicker';<Controller name="date" control={control} render={({ field }) => ( <DatePicker selected={field.value} onChange={field.onChange} onBlur={field.onBlur} /> )}/>
Regulation: wybieraj register kiedy tylko możesz, Controller tylko gdy musisz.
Walidacja — od prostych po zaawansowane
Zod daje świetne API do walidacji. Od prostej po wzajemne zależności pól:
Code
const SignupSchema = z.object({ email: z.string().email(), password: z.string().min(8).max(64), confirmPassword: z.string(), birthdate: z.coerce.date().max( new Date(Date.now() - 18 * 365 * 24 * 60 * 60 * 1000), 'Musisz mieć co najmniej 18 lat' ), terms: z.literal(true, { errorMap: () => ({ message: 'Musisz zaakceptować regulamin' }), }),}).refine( (data) => data.password === data.confirmPassword, { message: 'Hasła się nie zgadzają', path: ['confirmPassword'], });
refine pozwala na walidację międzypolową (hasła muszą się zgadzać, data końcowa musi być po starcie). Błąd jest umieszczany na path (w tym wypadku confirmPassword), więc user widzi go przy odpowiednim polu.
Asynchroniczna walidacja
Przykład: sprawdzenie, czy email nie jest zajęty. Zod nie obsługuje async natywnie, ale RHF ma dla tego osobne API:
Code
<input {...register('email', { validate: { unique: async (email) => { const response = await fetch(`/api/check-email?email=${email}`); const { available } = await response.json(); return available || 'Email jest już zajęty'; }, }, })} type="email"/>
Validate odpala się po blur pola (konfigurowalne przez mode w useForm). Dla user experience zwykle łączę to z debounce, żeby nie robić requestu po każdej literze.
Dynamic fields — useFieldArray
Formularz, gdzie user może dodawać / usuwać items (np. lista gości na wydarzenie, pozycje faktury, custom fields).
useFieldArray zarządza dodawaniem, usuwaniem, reordering. field.id to unikalny klucz (RHF generuje go wewnętrznie) — nie używaj index jako klucz, bo remove/reorder łamie state.
Multi-step forms
Wizard z krokami — najłatwiej przez FormProvider + osobne komponenty per krok.
Więcej kodu niż bezpośredni register, ale dostajesz konsystentny design system, automatyczne pokazywanie błędów, accessibility (labels, aria-describedby) out of the box.
Server-side walidacja — ten sam schema
Genialność Zod: używasz tego samego schema na kliencie i serwerze.
Code
// lib/schemas.tsimport { z } from 'zod';export const ContactSchema = z.object({ email: z.string().email(), message: z.string().min(10),});// app/api/contact/route.tsimport { ContactSchema } from '@/lib/schemas';export async function POST(request: Request) { const body = await request.json(); const parsed = ContactSchema.safeParse(body); if (!parsed.success) { return Response.json( { errors: parsed.error.flatten().fieldErrors }, { status: 400 } ); } // parsed.data jest zwalidowany i typowany await saveToDatabase(parsed.data); return Response.json({ success: true });}// components/ContactForm.tsximport { ContactSchema } from '@/lib/schemas';import { zodResolver } from '@hookform/resolvers/zod';const form = useForm<z.infer<typeof ContactSchema>>({ resolver: zodResolver(ContactSchema),});
Jedna walidacja, dwa miejsca. Bez duplikowania logiki. Jeśli dodasz pole, wystarczy zmienić schema — klient i serwer oba dostają update.
Zod 4 — na co uważać
Zod 4 jest stabilny i warto korzystać z jego modelu input / output, szczególnie gdy schema robi transformacje. To ważne w formularzach, bo typ danych wpisanych przez usera nie zawsze jest tym samym typem, który chcesz zapisać w bazie.
ProductFormInput opisuje dane z formularza, a ProductPayload dane po walidacji i transformacji. Dzięki temu nie musisz oszukiwać TypeScriptu, gdy HTML input zwraca string, a backend oczekuje liczby albo daty.
React Hook Form vs React 19 Actions
W 2026 roku mamy dwa podejścia do formularzy. Kiedy które?
React 19 Actions (opisane w osobnym artykule) — prostsze, działają bez klientowego JS, dobre dla prostych formularzy.
React Hook Form + Zod — więcej features, lepsze DX dla skomplikowanych formularzy, klientowa walidacja real-time.
Decision tree:
Case
Użyj
Kontakt, newsletter, prosty submit
React 19 Actions
Progressive enhancement (JS optional)
React 19 Actions
Login/signup z szybką walidacją
RHF + Zod
Multi-step wizard
RHF + Zod
Dynamic fields (array/nested)
RHF + Zod
Real-time walidacja (np. email availability)
RHF + Zod
Integracja z custom UI kit
RHF + Zod
Oba mogą współistnieć w jednym projekcie — Actions dla prostych submitów, RHF dla rozbudowanych formularzy.
Typowe pułapki
1. valueAsNumber dla liczb. Bez tego dostajesz string:
2. defaultValues vs values.defaultValues ustawia state raz (przy mount). values reaktywnie aktualizuje formularz gdy zmienia się external data — używaj, gdy ładujesz dane z API:
Code
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });const form = useForm({ values: user }); // automatyczne update gdy user się zmieni
3. Validation modes. Domyślnie RHF waliduje po submit. Dla real-time feedback ustaw mode: 'onChange' lub mode: 'onBlur':
Code
const form = useForm({ mode: 'onBlur', // waliduje przy blur każdego pola});
4. Reset po submit. Po udanym submit formularz nie resetuje się automatycznie. Dodaj ręcznie:
React Hook Form + Zod to stack, który pokrywa 90% potrzeb formularzowych. Dla prostych przypadków React 19 Actions mogą wystarczyć, ale gdy tylko dochodzi walidacja real-time, dynamic fields, multi-step wizard albo integracja z custom UI — RHF jest jedynym sensownym wyborem. Zod dokłada type-safe walidację po obu stronach z jednym schema.
Jeśli masz projekt z rozwalonymi formularzami i chcesz je posprzątać — napisz do mnie. W StriveLab React migracje formularzy z useState na RHF + Zod są jedną z najczęstszych usług i zwykle tniemy 50-70% kodu przy jednoczesnej poprawie UX.
Często zadawane pytania
Pracuję z tym zawodowo.
Jeśli chcesz uporządkować frontend, architekturę React i Next.js, poprawić jakość wdrożenia albo przyspieszyć development bez psucia maintainability, skontaktuj się ze mną. Na co dzień pracuję hands-on przy projektach, w których kod, UX i decyzje produktowe muszą działać razem.
Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.
Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.
Jak zbudować stronę w Astro, która dominuje w SEO — Core Web Vitals, sitemap, robots.txt, metadane, dane uporządkowane i GEO/AEO. Przewodnik techniczny z konkretnymi implementacjami.
Jak pobierać dane w React w 2026? Porównanie TanStack Query, SWR i useEffect. Kiedy Server Components wystarczą, kiedy potrzebujesz cache, invalidation, optimistic updates i infinite queries.
React 19 Actions zmieniają sposób pisania formularzy. Przewodnik po useActionState, useOptimistic, useFormStatus i akcjach na atrybucie form action. Z przykładami i migracją z onSubmit.