Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz
2026-02-03
Wprowadzenie
Formularze to serce większości aplikacji webowych – rejestracja, logowanie, edycja profilu, zamówienia, komentarze. Bez formularzy aplikacja to tylko galeria zdjęć. Ale formularze w React mogą być problematyczne: zarządzanie stanem każdego pola, walidacja, obsługa błędów, touched fields , disabled buttons , loading states ...
🔍 Co to znaczy dla początkujących?
Gdy tworzysz formularz, musisz śledzić wiele rzeczy jednocześnie:
touched fields - czy użytkownik kliknął w pole? (żeby nie pokazywać błędów od razu)
disabled buttons - przycisk "Zapisz" nieaktywny podczas wysyłania danych
loading states - stan "wysyłam dane na serwer, proszę czekać..."
To wszystko trzeba kodować ręcznie, co jest męczące i łatwo o błąd!
We wcześniejszych wpisach tworzyliśmy formularze "ręcznie" – useState dla każdego pola, onChange handlery, własna walidacja. To działa, ale jest męczące i podatne na błędy. Na szczęście istnieje React Hook Form – biblioteka, która upraszcza wszystko do minimum przy zachowaniu pełnej kontroli.
W tym wpisie nauczymy się React Hook Form od podstaw, poznamy integrację z bibliotekami walidacji (Zod , Yup ), zbudujemy złożone formularze z nested fields i arrays , a także zobaczymy jak łączyć wszystko z TypeScript dla pełnego type safety . To będzie bardzo praktyczny wpis – po nim będziesz mógł zbudować każdy formularz!
🔍 Wyjaśnienie terminów:
nested fields - pola zagnieżdżone, np. adres.ulica, adres.miasto (obiekt w obiekcie)
arrays - listy pól, np. lista hobby, lista produktów w koszyku
type safety - TypeScript pilnuje, żebyś nie pomylił typu danych (tekst zamiast liczby)
Dlaczego React Hook Form?
// ❌ Dużo boilerplate
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Walidacja
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email wymagany';
if (!password) newErrors.password = 'Hasło wymagane';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Submit...
}
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched({ ...touched, email: true })}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
{/* Powtarzamy dla każdego pola... */}
</form>
);
}
🔍 Co tu się dzieje?
Ten kod robi proste rzeczy w skomplikowany sposób:
useState dla każdego pola - osobna zmienna na email, hasło, błędy, touched, submitting...
onChange dla każdego inputa - przy każdej literze aktualizujemy stan
Ręczna walidacja - sami sprawdzamy czy email jest pusty, hasło puste...
Śledzenie touched - pamiętamy, w które pole użytkownik już kliknął
Dla 2 pól to już dużo kodu. Wyobraź sobie formularz z 10 polami! 🤯
Problemy:
Dużo state management
Powtarzalny kod
Trudna walidacja
Re-renderuje przy każdej zmianie
Brak type safety dla wartości
🔍 "Re-renderuje przy każdej zmianie" - co to znaczy?
Gdy piszesz "j" w polu email, React rysuje cały komponent od nowa. Potem "o", znowu rysuje. Potem "h", znowu rysuje. Dla prostych formularzy to OK, ale dla złożonych (z wieloma polami, dużymi listami) może być wolno.
// ✅ Proste, eleganckie
import { useForm } from 'react-hook-form';
interface LoginFormData {
email: string;
password: string;
}
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormData>();
function onSubmit(data: LoginFormData) {
console.log(data); // { email: '...', password: '...' }
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email wymagany' })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register('password', { required: 'Hasło wymagane' })}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Zaloguj</button>
</form>
);
}
🔍 Magia React Hook Form - jak to działa?
{...register('email')} to skrót, który robi za Ciebie:
Tworzy name="email" dla inputa
Podpina onChange (ale nie powoduje re-renderu!)
Podpina onBlur (śledzenie touched)
Zbiera wartość przy submit
handleSubmit(onSubmit) - React Hook Form automatycznie:
Zapobiega domyślnemu przeładowaniu strony
Waliduje wszystkie pola
Jeśli OK - wywołuje Twoją funkcję onSubmit z danymi
Jeśli błąd - pokazuje komunikaty w errors
Zalety:
Minimalny boilerplate
Wbudowana walidacja
TypeScript support
Małe re-rendery (tylko gdy potrzeba)
Łatwa integracja z UI libraries
Podstawy useForm
npm install react-hook-form
import { useForm } from 'react-hook-form';
function MyForm() {
const {
register, // Rejestruje input
handleSubmit, // Wrapper dla submit
formState, // Stan formularza (errors, isSubmitting, isDirty...)
watch, // Obserwuje wartości
setValue, // Ustawia wartość programowo
reset, // Resetuje formularz
getValues // Pobiera aktualne wartości
} = useForm();
return <form>...</form>;
}
<input {...register('email')} />
// Rozwija się do:
<input
name="email"
onChange={...}
onBlur={...}
ref={...}
/>
🌟 Przykład z życia: Formularz logowania do Gmaila
Gdy logujesz się do Gmaila, nie widzisz błędu "Email wymagany" od razu. Gmail czeka, aż klikniesz przycisk "Dalej". React Hook Form działa tak samo:
function GmailLikeLogin() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log('Logowanie z:', data.email);
// Tutaj wysłałbyś dane do API
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', { required: 'Podaj adres email' })}
placeholder="Email"
/>
{/* Błąd pojawi się DOPIERO po kliknięciu "Dalej" */}
{errors.email && <p style={{color: 'red'}}>{errors.email.message}</p>}
<button type="submit">Dalej</button>
</form>
);
}
<input
{...register('email', {
required: 'Email jest wymagany',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Nieprawidłowy format email'
},
minLength: {
value: 5,
message: 'Email musi mieć min 5 znaków'
}
})}
/>
🔍 Reguły walidacji krok po kroku:
required - pole nie może być puste
pattern - wartość musi pasować do wyrażenia regularnego (regex)
minLength - minimalna liczba znaków
maxLength - maksymalna liczba znaków
min - minimalna wartość (dla liczb)
max - maksymalna wartość (dla liczb)
Każda reguła może mieć message - tekst błędu do pokazania użytkownikowi.
🌟 Przykład: Formularz rejestracji z hasłem
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
{/* Username - tylko litery i cyfry */}
<input
{...register('username', {
required: 'Login jest wymagany',
minLength: { value: 3, message: 'Min 3 znaki' },
pattern: {
value: /^[a-zA-Z0-9]+$/,
message: 'Tylko litery i cyfry'
}
})}
placeholder="Login"
/>
{errors.username && <p>{errors.username.message}</p>}
{/* Password - minimum 8 znaków, musi zawierać cyfrę */}
<input
type="password"
{...register('password', {
required: 'Hasło jest wymagane',
minLength: { value: 8, message: 'Min 8 znaków' },
pattern: {
value: /(?=.*\d)/,
message: 'Hasło musi zawierać cyfrę'
}
})}
placeholder="Hasło"
/>
{errors.password && <p>{errors.password.message}</p>}
<button>Zarejestruj się</button>
</form>
);
}
To jak działa rejestracja na Facebooku czy Twitterze - sprawdzają format danych zanim wyślą na serwer.
💪 Ćwiczenie 1: Walidacja formularza zamówienia pizzy
Stwórz formularz zamówienia pizzy z walidacją:
Imię i Nazwisko - wymagane, min 5 znaków łącznie
Telefon - wymagany, dokładnie 9 cyfr (pattern: /^\d{9}$/)
Adres - wymagany, min 10 znaków
Kod pocztowy - format XX-XXX (pattern: /^\d{2}-\d{3}$/)
Liczba pizz - liczba od 1 do 10
Pokaż odpowiednie komunikaty błędów dla każdego pola!
TypeScript + React Hook Form
interface LoginFormData {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const { register, handleSubmit } = useForm<LoginFormData>();
// TypeScript wie że data ma typ LoginFormData
const onSubmit = (data: LoginFormData) => {
console.log(data.email); // ✅ OK
console.log(data.firstName); // ❌ Error - nie ma takiego pola!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('password')} />
<input {...register('rememberMe')} type="checkbox" />
</form>
);
}
🔍 Dlaczego TypeScript to dobry pomysł?
Bez TypeScript możesz napisać data.emial (literówka!) i dopiero w przeglądarce zobaczysz, że coś nie działa. TypeScript podkreśli błąd OD RAZU w edytorze:
❌ register('emial') - TypeScript: "Nie ma pola 'emial', masz na myśli 'email'?"
✅ register('email') - OK!
To jak autocorrect w telefonie - wyłapuje błędy zanim narobisz problemów!
🌟 Przykład: Formularz profilu użytkownika
interface UserProfile {
firstName: string;
lastName: string;
age: number;
email: string;
newsletter: boolean;
}
function ProfileForm() {
const { register, handleSubmit } = useForm<UserProfile>({
defaultValues: {
firstName: '',
lastName: '',
age: 18,
email: '',
newsletter: false
}
});
const onSubmit = (data: UserProfile) => {
// TypeScript GWARANTUJE że dostaniesz właściwe typy:
const fullName = data.firstName + ' ' + data.lastName; // string + string ✅
const isAdult = data.age >= 18; // number >= number ✅
// data.age + data.firstName - ❌ TypeScript: "Nie możesz dodać liczby i stringa!"
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} placeholder="Imię" />
<input {...register('lastName')} placeholder="Nazwisko" />
<input {...register('age', { valueAsNumber: true })} type="number" />
<input {...register('email')} type="email" />
<input {...register('newsletter')} type="checkbox" /> Newsletter?
<button>Zapisz profil</button>
</form>
);
}
Zwróć uwagę na valueAsNumber: true - bez tego age byłoby stringiem "18", a nie liczbą 18!
interface UserFormData {
name: string;
address: {
street: string;
city: string;
zipCode: string;
};
}
function UserForm() {
const { register } = useForm<UserFormData>();
return (
<form>
<input {...register('name')} />
<input {...register('address.street')} />
<input {...register('address.city')} />
<input {...register('address.zipCode')} />
</form>
);
}
🔍 Zagnieżdżone obiekty - po co?
W prawdziwych aplikacjach dane są zorganizowane hierarchicznie. Np. formularz edycji użytkownika w panelu admina:
{
personalInfo: {
firstName: "Jan",
lastName: "Kowalski"
},
contact: {
email: "jan@example.com",
phone: "123456789"
},
address: {
street: "Główna 5",
city: "Warszawa",
zipCode: "00-001"
}
}
Zamiast płaskiej struktury (firstName, lastName, email, phone...) masz logiczne grupy. To łatwiejsze do wysłania na backend!
💪 Ćwiczenie 2: Formularz z TypeScript
Stwórz typowany formularz rezerwacji hotelu:
interface HotelBooking {
guest: {
firstName: string;
lastName: string;
email: string;
};
room: {
type: 'single' | 'double' | 'suite'; // tylko te 3 opcje!
checkIn: string; // data w formacie YYYY-MM-DD
checkOut: string;
guests: number;
};
specialRequests: string;
}
Dodaj walidację:
Wszystkie pola gościa wymagane
Email musi być poprawny
Liczba gości: 1-4
checkOut musi być PÓŹNIEJ niż checkIn
Walidacja
<input
{...register('username', {
required: 'Pole wymagane',
minLength: { value: 3, message: 'Min 3 znaki' },
maxLength: { value: 20, message: 'Max 20 znaków' },
pattern: {
value: /^[a-zA-Z0-9_]+$/,
message: 'Tylko litery, cyfry i _'
}
})}
/>
<input
type="email"
{...register('email', {
required: 'Email wymagany',
validate: {
matchPattern: (v) => /\S+@\S+\.\S+/.test(v) || 'Nieprawidłowy email',
emailAvailable: async (email) => {
const response = await fetch(`/api/check-email?email=${email}`);
const available = await response.json();
return available || 'Email już zajęty';
}
}
})}
/>
<input
type="number"
{...register('age', {
required: 'Wiek wymagany',
valueAsNumber: true,
min: { value: 18, message: 'Musisz mieć min 18 lat' },
max: { value: 100, message: 'Max 100 lat' }
})}
/>
🔍 Asynchroniczna walidacja - co to?
W przykładzie powyżej emailAvailable to asynchroniczna walidacja . Działa tak:
Użytkownik wpisuje email: "jan@example.com"
Klikasz "Zarejestruj się"
React Hook Form wysyła zapytanie do serwera: "Czy ten email jest wolny?"
Serwer odpowiada: "Nie, ktoś już go używa!"
Pokazujesz błąd: "Email już zajęty"
To jak sprawdzanie dostępności nicku w grze - musisz zapytać serwer, czy nick jest wolny!
function validatePassword(value: string) {
if (value.length < 8) {
return 'Hasło musi mieć min 8 znaków';
}
if (!/[A-Z]/.test(value)) {
return 'Hasło musi zawierać wielką literę';
}
if (!/[0-9]/.test(value)) {
return 'Hasło musi zawierać cyfrę';
}
return true;
}
<input
type="password"
{...register('password', { validate: validatePassword })}
/>
🔍 Jak działa custom validation?
Funkcja walidacji dostaje wartość pola i sprawdza warunki:
Jeśli coś jest źle → zwraca string z błędem (np. "Hasło musi mieć min 8 znaków")
Jeśli wszystko OK → zwraca true
React Hook Form testuje warunki kolejno - zatrzymuje się przy pierwszym błędzie!
🌟 Przykład: Walidacja hasła jak w banku
Banki mają surowe wymagania dla haseł. Stwórzmy taką walidację:
function validateBankPassword(password: string) {
// Min 12 znaków
if (password.length < 12) {
return 'Hasło musi mieć minimum 12 znaków';
}
// Musi być wielka litera
if (!/[A-Z]/.test(password)) {
return 'Hasło musi zawierać wielką literę';
}
// Musi być mała litera
if (!/[a-z]/.test(password)) {
return 'Hasło musi zawierać małą literę';
}
// Musi być cyfra
if (!/[0-9]/.test(password)) {
return 'Hasło musi zawierać cyfrę';
}
// Musi być znak specjalny
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return 'Hasło musi zawierać znak specjalny (!@#$%...)';
}
// Nie może zawierać 3 identycznych znaków pod rząd
if (/(.)\1\1/.test(password)) {
return 'Hasło nie może zawierać 3 identycznych znaków pod rząd';
}
return true; // Wszystko OK!
}
function BankRegistration() {
const { register, formState: { errors } } = useForm();
return (
<form>
<input
type="password"
{...register('password', { validate: validateBankPassword })}
placeholder="Hasło"
/>
{errors.password && (
<p style={{color: 'red'}}>{errors.password.message}</p>
)}
</form>
);
}
Hasło "abc123" zostanie odrzucone, ale "MyBank2024!" przejdzie! 🔒
const { register, watch } = useForm<FormData>();
const password = watch('password');
<input type="password" {...register('password')} />
<input
type="password"
{...register('confirmPassword', {
validate: (value) => value === password || 'Hasła muszą być takie same'
})}
/>
🔍 "watch" - obserwowanie wartości na żywo
watch('password') to jak kamerka CCTV na polu "password" - cały czas patrzy jaka jest aktualna wartość. Gdy użytkownik pisze w polu "Powtórz hasło", React Hook Form porównuje to z aktualną wartością pierwszego pola.
Przykład krok po kroku:
Użytkownik wpisuje w "Hasło": "Tajne123"
password = "Tajne123"
Użytkownik wpisuje w "Powtórz hasło": "Tajne124"
Walidacja: "Tajne124" === "Tajne123"? NIE! ❌
Pokazuje błąd: "Hasła muszą być takie same"
💪 Ćwiczenie 3: Formularz zmiany hasła
Stwórz formularz zmiany hasła z następującymi walidacjami:
Stare hasło - wymagane, min 8 znaków
Nowe hasło - wymagane, min 8 znaków, musi zawierać: wielką literę, małą literę, cyfrę
Nowe hasło NIE MOŻE być takie samo jak stare
Powtórz nowe hasło - musi być identyczne z nowym hasłem
Podpowiedź: użyj watch do porównania haseł!
Integracja z Zod
Zod to nowoczesna biblioteka walidacji z pełnym TypeScript support.
🔍 Czym jest Zod i dlaczego go używać?
Zod to biblioteka do definiowania "schematów" - opisów jak powinny wyglądać Twoje dane. Wyobraź sobie przepis na ciasto:
"Mąka - 500g, nie mniej!" → z.number().min(500)
"Jajka - 3 sztuki" → z.number().int().min(3).max(3)
"Mleko - opcjonalne" → z.string().optional()
Zalety Zod vs zwykła walidacja:
Jeden schemat = walidacja + typy TypeScript - nie musisz pisać 2 razy
Czytelniejszy kod - schema opisuje cały formularz w jednym miejscu
Łatwa walidacja skomplikowanych struktur - nested objects, arrays, daty...
Transformacje - automatycznie zmienia stringi na liczby, daty, etc.
npm install zod @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Schema walidacji
const schema = z.object({
username: z.string()
.min(3, 'Min 3 znaki')
.max(20, 'Max 20 znaków')
.regex(/^[a-zA-Z0-9_]+$/, 'Tylko litery, cyfry i _'),
email: z.string()
.email('Nieprawidłowy email'),
age: z.number()
.min(18, 'Musisz mieć min 18 lat')
.max(100, 'Max 100 lat'),
password: z.string()
.min(8, 'Min 8 znaków')
.regex(/[A-Z]/, 'Musi zawierać wielką literę')
.regex(/[0-9]/, 'Musi zawierać cyfrę'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: 'Hasła muszą być takie same',
path: ['confirmPassword']
});
// TypeScript automatycznie wywnioskuje typ!
type FormData = z.infer<typeof schema>;
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema)
});
function onSubmit(data: FormData) {
console.log(data); // Type-safe!
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Zarejestruj</button>
</form>
);
}
🔍 Jak działa "resolver"?
resolver: zodResolver(schema) to "tłumacz" między React Hook Form a Zod:
React Hook Form zbiera dane z formularza
Przekazuje je do zodResolver
zodResolver pyta Zod: "Czy te dane są OK?"
Zod sprawdza według schematu i mówi: "TAK" lub "NIE + lista błędów"
zodResolver przekazuje odpowiedź z powrotem do React Hook Form
Widzisz błędy w errors.username, errors.email, etc.
🌟 Przykład: Formularz rezerwacji lotu
const flightBookingSchema = z.object({
passenger: z.object({
firstName: z.string().min(2, 'Imię zbyt krótkie'),
lastName: z.string().min(2, 'Nazwisko zbyt krótkie'),
dateOfBirth: z.string()
.refine((date) => {
const age = new Date().getFullYear() - new Date(date).getFullYear();
return age >= 18;
}, 'Musisz mieć min 18 lat')
}),
flight: z.object({
from: z.string().length(3, 'Kod lotniska to 3 litery (np. WAW)'),
to: z.string().length(3, 'Kod lotniska to 3 litery (np. JFK)'),
departureDate: z.string()
.refine((date) => new Date(date) > new Date(), 'Data musi być w przyszłości')
}),
seatClass: z.enum(['economy', 'business', 'first'], {
errorMap: () => ({ message: 'Wybierz klasę: economy, business lub first' })
}),
extraBaggage: z.number().int().min(0).max(3, 'Max 3 dodatkowe bagaże')
});
type FlightBooking = z.infer<typeof flightBookingSchema>;
function FlightBookingForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FlightBooking>({
resolver: zodResolver(flightBookingSchema)
});
const onSubmit = (data: FlightBooking) => {
console.log('Rezerwacja lotu:', data);
// Wyślij na backend...
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h3>Dane pasażera</h3>
<input {...register('passenger.firstName')} placeholder="Imię" />
{errors.passenger?.firstName && <p>{errors.passenger.firstName.message}</p>}
<input {...register('passenger.lastName')} placeholder="Nazwisko" />
{errors.passenger?.lastName && <p>{errors.passenger.lastName.message}</p>}
<input type="date" {...register('passenger.dateOfBirth')} />
{errors.passenger?.dateOfBirth && <p>{errors.passenger.dateOfBirth.message}</p>}
<h3>Szczegóły lotu</h3>
<input {...register('flight.from')} placeholder="Z (WAW)" maxLength={3} />
{errors.flight?.from && <p>{errors.flight.from.message}</p>}
<input {...register('flight.to')} placeholder="Do (JFK)" maxLength={3} />
{errors.flight?.to && <p>{errors.flight.to.message}</p>}
<input type="date" {...register('flight.departureDate')} />
{errors.flight?.departureDate && <p>{errors.flight.departureDate.message}</p>}
<select {...register('seatClass')}>
<option value="">Wybierz klasę</option>
<option value="economy">Ekonomiczna</option>
<option value="business">Biznes</option>
<option value="first">Pierwsza</option>
</select>
{errors.seatClass && <p>{errors.seatClass.message}</p>}
<input type="number" {...register('extraBaggage', { valueAsNumber: true })} />
{errors.extraBaggage && <p>{errors.extraBaggage.message}</p>}
<button>Rezerwuj lot</button>
</form>
);
}
Zod sprawdza wszystko automatycznie: czy kody lotnisk mają 3 litery, czy data jest w przyszłości, czy pasażer jest pełnoletni!
const schema = z.object({
email: z.string()
.email()
.refine(async (email) => {
const response = await fetch(`/api/check-email?email=${email}`);
return response.ok;
}, 'Email już zajęty'),
phone: z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Nieprawidłowy numer telefonu')
.optional(),
birthDate: z.string()
.transform((str) => new Date(str))
.refine((date) => {
const age = new Date().getFullYear() - date.getFullYear();
return age >= 18;
}, 'Musisz mieć min 18 lat'),
agreeToTerms: z.boolean()
.refine((val) => val === true, 'Musisz zaakceptować regulamin'),
role: z.enum(['user', 'admin', 'moderator']),
tags: z.array(z.string()).min(1, 'Wybierz min 1 tag').max(5, 'Max 5 tagów'),
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{2}-\d{3}$/, 'Format: XX-XXX')
})
});
🔍 Przydatne metody Zod:
.optional()
Pole może być puste
.nullable()
Pole może być null
.default(value)
Wartość domyślna gdy puste
.transform()
Zmienia wartość (string→number, string→Date)
.refine()
Własna logika walidacji
.enum([...])
Wartość musi być z listy
💪 Ćwiczenie 4: Formularz aplikacji o pracę z Zod
Stwórz schemat Zod dla formularza aplikacji o pracę:
const jobApplicationSchema = z.object({
applicant: {
fullName: // min 5 znaków
email: // poprawny email
phone: // opcjonalny, format +XX XXXXXXXXX
},
position: // enum: 'frontend', 'backend', 'fullstack'
experience: // liczba całkowita, 0-50 lat
availableFrom: // data, musi być w przyszłości
salary: {
min: // liczba, min 3000
max: // liczba, musi być >= min
},
skills: // tablica stringów, min 1, max 10
cv: // opcjonalny string (URL)
});
Sprawdź, czy schemat działa poprawnie z różnymi danymi!
Arrays (useFieldArray)
Dla dynamicznych list pól:
🔍 Po co useFieldArray?
Wyobraź sobie formularz zamówienia w sklepie. Użytkownik może dodawać wiele produktów:
Produkt 1: Klawiatura, ilość: 2
Produkt 2: Myszka, ilość: 1
Produkt 3: Monitor, ilość: 1
+ przycisk "Dodaj kolejny produkt"
Nie wiesz z góry ile produktów użytkownik wybierze - może 1, może 10! useFieldArray pozwala dynamicznie dodawać/usuwać pola.
import { useForm, useFieldArray } from 'react-hook-form';
interface FormData {
users: Array<{
name: string;
email: string;
}>;
}
function DynamicForm() {
const { register, control, handleSubmit } = useForm<FormData>({
defaultValues: {
users: [{ name: '', email: '' }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'users'
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`users.${index}.name`)} placeholder="Name" />
<input {...register(`users.${index}.email`)} placeholder="Email" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', email: '' })}>
Add User
</button>
<button type="submit">Submit</button>
</form>
);
}
🔍 useFieldArray - funkcje:
fields - tablica aktualnych pól (do .map())
append(data) - dodaje nowe pole na koniec
remove(index) - usuwa pole o danym indeksie
insert(index, data) - wstawia pole w określone miejsce
update(index, data) - aktualizuje pole
move(from, to) - przesuwa pole (drag & drop)
Ważne: Używaj field.id jako key w .map() - React potrzebuje unikalnych kluczy!
🌟 Przykład: Lista zakupów
interface ShoppingListForm {
items: Array<{
product: string;
quantity: number;
price: number;
}>;
}
function ShoppingListForm() {
const { register, control, handleSubmit, watch } = useForm<ShoppingListForm>({
defaultValues: {
items: [{ product: '', quantity: 1, price: 0 }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
// Oblicz sumę na żywo
const items = watch('items');
const totalPrice = items.reduce((sum, item) =>
sum + (item.quantity || 0) * (item.price || 0), 0
);
return (
<form onSubmit={handleSubmit(data => {
console.log('Zakupy:', data);
console.log('Do zapłaty:', totalPrice, 'zł');
})}>
<h2>Lista zakupów</h2>
{fields.map((field, index) => (
<div key={field.id} style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0' }}>
<h4>Produkt {index + 1}</h4>
<input
{...register(`items.${index}.product`)}
placeholder="Nazwa produktu"
/>
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
placeholder="Ilość"
min="1"
/>
<input
type="number"
{...register(`items.${index}.price`, { valueAsNumber: true })}
placeholder="Cena"
step="0.01"
min="0"
/>
<p>
Suma: {(items[index]?.quantity || 0) * (items[index]?.price || 0)} zł
</p>
{fields.length > 1 && (
<button type="button" onClick={() => remove(index)}>
🗑️ Usuń
</button>
)}
</div>
))}
<button
type="button"
onClick={() => append({ product: '', quantity: 1, price: 0 })}
>
➕ Dodaj produkt
</button>
<h3>Razem do zapłaty: {totalPrice.toFixed(2)} zł</h3>
<button type="submit">Zamów</button>
</form>
);
}
Ten formularz oblicza sumę na żywo gdy zmieniasz ilość lub cenę!
const schema = z.object({
users: z.array(
z.object({
name: z.string().min(1, 'Name required'),
email: z.string().email('Invalid email')
})
).min(1, 'At least one user required')
});
type FormData = z.infer<typeof schema>;
function DynamicFormWithValidation() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
users: [{ name: '', email: '' }]
}
});
const { fields, append, remove } = useFieldArray({ control, name: 'users' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`users.${index}.name`)} />
{errors.users?.[index]?.name && (
<span>{errors.users[index]?.name?.message}</span>
)}
<input {...register(`users.${index}.email`)} />
{errors.users?.[index]?.email && (
<span>{errors.users[index]?.email?.message}</span>
)}
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
{errors.users?.message && <span>{errors.users.message}</span>}
<button type="button" onClick={() => append({ name: '', email: '' })}>
Add User
</button>
<button type="submit">Submit</button>
</form>
);
}
💪 Ćwiczenie 5: Formularz dodawania uczestników wydarzenia
Stwórz formularz z dynamiczną listą uczestników:
Minimum 1 uczestnik, maksimum 10
Każdy uczestnik ma: imię (min 2 znaki), nazwisko (min 2 znaki), email
Przycisk "Dodaj uczestnika" (zablokowany gdy jest już 10)
Przycisk "Usuń" przy każdym uczestniku (zablokowany gdy jest tylko 1)
Pokaż licznik: "Uczestników: X/10"
Użyj Zod do walidacji
Controlled Components
Czasem potrzebujesz controlled components (np. dla custom inputs ):
🔍 Controlled vs Uncontrolled - różnica
Uncontrolled (domyślne w React Hook Form):
Wartość trzymana w DOM (w samym <input>)
React Hook Form czyta ją dopiero przy submit
Szybkie - nie ma re-renderów
Używaj dla zwykłych inputów
Controlled (z Controller):
Wartość trzymana w state React
React Hook Form wie o niej cały czas
Wolniejsze - re-render przy każdej zmianie
Konieczne dla: custom components (React Select, Material-UI), real-time formatowania (np. maska telefonu)
import { Controller } from 'react-hook-form';
// Custom Select component
function CustomSelect({ value, onChange, options }: any) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((opt: any) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
function FormWithController() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="country"
control={control}
defaultValue=""
rules={{ required: 'Country required' }}
render={({ field, fieldState: { error } }) => (
<div>
<CustomSelect
{...field}
options={[
{ value: 'pl', label: 'Poland' },
{ value: 'us', label: 'USA' }
]}
/>
{error && <span>{error.message}</span>}
</div>
)}
/>
<button type="submit">Submit</button>
</form>
);
}
🔍 Jak działa Controller?
<Controller> to adapter między React Hook Form a custom componentami:
name - nazwa pola (jak w register)
control - obiekt kontrolny z useForm
render - funkcja zwracająca Twój custom component
field - zawiera: value, onChange, onBlur (podpinasz do custom componentu)
fieldState - zawiera: error, isDirty, isTouched
{...field} rozkłada się na: value={field.value} onChange={field.onChange} onBlur={field.onBlur}
🌟 Przykład: Formatowanie numeru telefonu na żywo
import { Controller } from 'react-hook-form';
// Custom input z formatowaniem telefonu
function PhoneInput({ value, onChange }: any) {
const formatPhone = (input: string) => {
// Usuń wszystko oprócz cyfr
const numbers = input.replace(/\D/g, '');
// Format: XXX-XXX-XXX
if (numbers.length <= 3) return numbers;
if (numbers.length <= 6) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
return `${numbers.slice(0, 3)}-${numbers.slice(3, 6)}-${numbers.slice(6, 9)}`;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhone(e.target.value);
onChange(formatted);
};
return (
<input
value={value}
onChange={handleChange}
placeholder="XXX-XXX-XXX"
maxLength={11} // 9 cyfr + 2 myślniki
/>
);
}
function ContactForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<Controller
name="phone"
control={control}
defaultValue=""
rules={{
required: 'Telefon wymagany',
pattern: {
value: /^\d{3}-\d{3}-\d{3}$/,
message: 'Nieprawidłowy format (XXX-XXX-XXX)'
}
}}
render={({ field, fieldState: { error } }) => (
<div>
<PhoneInput {...field} />
{error && <p style={{color: 'red'}}>{error.message}</p>}
</div>
)}
/>
<button>Wyślij</button>
</form>
);
}
Gdy użytkownik wpisuje "123456789", widzą "123-456-789" automatycznie!
Watch – obserwowanie wartości
🔍 Kiedy używać watch?
Używaj watch gdy potrzebujesz wartości pola PRZED submitem:
Pokazanie podglądu (np. preview awatara)
Obliczenia na żywo (suma w koszyku)
Warunkowe pokazywanie pól ("Jeśli zaznaczysz 'Inny', pokaż pole tekstowe")
Zależna walidacja (potwierdzenie hasła)
function FormWithWatch() {
const { register, watch } = useForm();
// Watch pojedyncze pole
const email = watch('email');
// Watch wiele pól
const [name, age] = watch(['name', 'age']);
// Watch wszystko
const allValues = watch();
// Watch z callback
useEffect(() => {
const subscription = watch((value, { name, type }) => {
console.log(`Field ${name} changed to:`, value);
});
return () => subscription.unsubscribe();
}, [watch]);
return (
<form>
<input {...register('email')} />
<p>Current email: {email}</p>
<input {...register('name')} />
<input type="number" {...register('age', { valueAsNumber: true })} />
<p>Name: {name}, Age: {age}</p>
</form>
);
}
🌟 Przykład: Kalkulator rabatu w sklepie
function DiscountCalculator() {
const { register, watch } = useForm({
defaultValues: {
price: 0,
quantity: 1,
discountCode: ''
}
});
const price = watch('price');
const quantity = watch('quantity');
const discountCode = watch('discountCode');
// Kody rabatowe
const discounts: Record<string, number> = {
'WELCOME10': 0.10, // 10% rabatu
'SUMMER20': 0.20, // 20% rabatu
'VIP30': 0.30 // 30% rabatu
};
const discount = discounts[discountCode.toUpperCase()] || 0;
const subtotal = price * quantity;
const discountAmount = subtotal * discount;
const total = subtotal - discountAmount;
return (
<div>
<h2>Kalkulator zamówienia</h2>
<label>
Cena produktu:
<input
type="number"
{...register('price', { valueAsNumber: true })}
step="0.01"
min="0"
/>
</label>
<label>
Ilość:
<input
type="number"
{...register('quantity', { valueAsNumber: true })}
min="1"
/>
</label>
<label>
Kod rabatowy:
<input {...register('discountCode')} placeholder="WELCOME10" />
</label>
<div style={{marginTop: '20px', padding: '15px', border: '2px solid green'}}>
<h3>Podsumowanie:</h3>
<p>Cena x Ilość: {subtotal.toFixed(2)} zł</p>
{discount > 0 && (
<p style={{color: 'green'}}>
Rabat {(discount * 100).toFixed(0)}%: -{discountAmount.toFixed(2)} zł
</p>
)}
<h2>Do zapłaty: {total.toFixed(2)} zł</h2>
</div>
</div>
);
}
Wartości aktualizują się natychmiast gdy użytkownik zmienia cenę, ilość lub wpisuje kod rabatowy!
setValue i reset
🔍 Po co setValue i reset?
Czasami musisz zmienić wartości formularza z kodu , nie przez input:
setValue - zmienia wartość pojedynczego pola
reset - resetuje cały formularz (do wartości początkowych lub nowych)
Przykłady użycia:
Edycja profilu - wczytujesz dane użytkownika z API
Przyciski "Wyczyść formularz" lub "Przywróć domyślne"
Auto-uzupełnianie (np. "Użyj adresu do faktury jako adres dostawy")
Po pomyślnym zapisie - czyścisz formularz do stanu początkowego
function FormWithSetValue() {
const { register, setValue, reset, handleSubmit } = useForm<FormData>();
function loadUserData() {
// Załaduj dane z API
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 30
};
// Ustaw pojedyncze pole
setValue('name', userData.name);
// Lub wszystkie naraz
reset(userData);
}
function clearForm() {
reset(); // Resetuje do defaultValues
}
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('name')} />
<input {...register('email')} />
<input type="number" {...register('age', { valueAsNumber: true })} />
<button type="button" onClick={loadUserData}>Load Data</button>
<button type="button" onClick={clearForm}>Clear</button>
<button type="submit">Submit</button>
</form>
);
}
🌟 Przykład: Formularz edycji profilu z załadowaniem danych
interface UserProfile {
firstName: string;
lastName: string;
email: string;
phone: string;
bio: string;
}
function EditProfileForm({ userId }: { userId: number }) {
const [isLoading, setIsLoading] = useState(true);
const { register, reset, handleSubmit, formState: { isDirty } } = useForm<UserProfile>();
// Załaduj dane użytkownika przy montowaniu komponentu
useEffect(() => {
async function loadProfile() {
setIsLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Ustaw wszystkie pola naraz
reset({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
bio: data.bio
});
} catch (error) {
console.error('Błąd ładowania profilu:', error);
} finally {
setIsLoading(false);
}
}
loadProfile();
}, [userId, reset]);
const onSubmit = async (data: UserProfile) => {
await fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Po pomyślnym zapisie - oznacz formularz jako "czysty"
reset(data);
alert('Profil zaktualizowany!');
};
if (isLoading) {
return <div>Ładowanie profilu...</div>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Edytuj profil</h2>
<input {...register('firstName')} placeholder="Imię" />
<input {...register('lastName')} placeholder="Nazwisko" />
<input {...register('email')} type="email" placeholder="Email" />
<input {...register('phone')} placeholder="Telefon" />
<textarea {...register('bio')} placeholder="O mnie" rows={5} />
{/* Przycisk aktywny tylko gdy coś zmieniono */}
<button type="submit" disabled={!isDirty}>
Zapisz zmiany
</button>
{/* Przycisk cofnięcia zmian */}
<button type="button" onClick={() => reset()} disabled={!isDirty}>
Anuluj zmiany
</button>
</form>
);
}
isDirty to flaga mówiąca czy użytkownik coś zmienił. Przycisk "Zapisz" jest nieaktywny dopóki nie wprowadzisz zmian!
🌟 Przykład: Auto-kopiowanie adresu
function ShippingForm() {
const { register, setValue, watch } = useForm({
defaultValues: {
billingAddress: { street: '', city: '', zipCode: '' },
shippingAddress: { street: '', city: '', zipCode: '' },
sameAsbilling: false
}
});
const sameAsBinding = watch('sameAsBinding');
const billingAddress = watch('billingAddress');
// Gdy użytkownik zaznaczy "Taki sam jak adres do faktury"
const handleCopyAddress = (checked: boolean) => {
if (checked) {
setValue('shippingAddress.street', billingAddress.street);
setValue('shippingAddress.city', billingAddress.city);
setValue('shippingAddress.zipCode', billingAddress.zipCode);
}
};
return (
<form>
<h3>Adres do faktury</h3>
<input {...register('billingAddress.street')} placeholder="Ulica" />
<input {...register('billingAddress.city')} placeholder="Miasto" />
<input {...register('billingAddress.zipCode')} placeholder="Kod" />
<h3>Adres dostawy</h3>
<label>
<input
type="checkbox"
{...register('sameAsBinding')}
onChange={(e) => handleCopyAddress(e.target.checked)}
/>
Taki sam jak adres do faktury
</label>
<input
{...register('shippingAddress.street')}
placeholder="Ulica"
disabled={sameAsBinding}
/>
<input
{...register('shippingAddress.city')}
placeholder="Miasto"
disabled={sameAsBinding}
/>
<input
{...register('shippingAddress.zipCode')}
placeholder="Kod"
disabled={sameAsBinding}
/>
<button>Zamów</button>
</form>
);
}
Kliknięcie checkboxa automatycznie kopiuje adres - jak w sklepach online! 🛒
💪 Ćwiczenie 6: Formularz z szablonami
Stwórz formularz z możliwością wyboru szablonu:
Formularz ma pola: tytuł, treść, kategoria, tagi
3 przyciski z szablonami:
"Blog post" - ustawia kategoria="blog", tagi=["tech", "tutorial"]
"News" - ustawia kategoria="news", tagi=["breaking"]
"Tutorial" - ustawia kategoria="tutorial", tagi=["howto", "guide"]
Przycisk "Wyczyść wszystko" który resetuje formularz
Użyj setValue do ustawiania wartości po kliknięciu szablonu
Error Handling
🔍 Rodzaje błędów w formularzach
W formularzach spotykasz 2 rodzaje błędów:
Błędy walidacji (client-side) - wykryte przez React Hook Form PRZED wysłaniem:
"Email wymagany"
"Hasło zbyt krótkie"
"Nieprawidłowy format"
Błędy z API (server-side) - serwer odrzucił dane PO wysłaniu:
"Email już zajęty" (serwer sprawdził w bazie)
"Niewłaściwe uprawnienia"
"Błąd serwera"
React Hook Form obsługuje oba rodzaje!
function ErrorDisplay() {
const { register, formState: { errors }, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<div>
<input {...register('email', { required: 'Email required' })} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
{/* Lub pomocniczy komponent */}
<ErrorMessage errors={errors} name="email" />
</form>
);
}
// Pomocniczy komponent
function ErrorMessage({ errors, name }: { errors: any; name: string }) {
const error = errors[name];
return error ? <span className="error">{error.message}</span> : null;
}
function FormWithApiErrors() {
const { register, setError, handleSubmit, formState: { errors } } = useForm();
async function onSubmit(data: any) {
try {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data)
});
} catch (error: any) {
// API zwróciło błędy
if (error.fieldErrors) {
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as any, {
type: 'manual',
message: message as string
});
});
}
// Lub globalny błąd
setError('root', {
type: 'manual',
message: 'Registration failed'
});
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div className="error">{errors.root.message}</div>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Register</button>
</form>
);
}
🔍 setError - jak to działa?
setError pozwala ręcznie dodać błąd do pola:
setError('email', {
type: 'manual', // typ błędu (możesz dać dowolny string)
message: 'Email zajęty' // wiadomość do pokazania
});
Specjalny error: 'root' - to globalny błąd NIE związany z konkretnym polem:
setError('root', {
type: 'manual',
message: 'Serwer nie odpowiada, spróbuj później'
});
Pokaże się nad formularzem jako ogólny komunikat błędu.
🌟 Przykład: Obsługa błędów z API
interface RegisterForm {
username: string;
email: string;
password: string;
}
function RegistrationWithApiErrors() {
const { register, setError, handleSubmit, formState: { errors, isSubmitting } } = useForm<RegisterForm>();
const onSubmit = async (data: RegisterForm) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = await response.json();
// Backend zwrócił błędy dla konkretnych pól
if (errorData.errors) {
// errorData.errors = { username: "Zajęte", email: "Nieprawidłowy" }
Object.keys(errorData.errors).forEach((field) => {
setError(field as keyof RegisterForm, {
type: 'server',
message: errorData.errors[field]
});
});
return;
}
// Ogólny błąd serwera
setError('root', {
type: 'server',
message: errorData.message || 'Coś poszło nie tak'
});
return;
}
// Sukces!
alert('Rejestracja udana!');
} catch (error) {
// Błąd sieci (np. brak internetu)
setError('root', {
type: 'network',
message: 'Błąd połączenia. Sprawdź internet.'
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Globalny błąd na górze */}
{errors.root && (
<div style={{
backgroundColor: '#fee',
border: '1px solid red',
padding: '10px',
marginBottom: '15px',
borderRadius: '4px'
}}>
❌ {errors.root.message}
</div>
)}
<div>
<input {...register('username', { required: 'Login wymagany' })} placeholder="Login" />
{errors.username && <p style={{color: 'red'}}>{errors.username.message}</p>}
</div>
<div>
<input {...register('email', { required: 'Email wymagany' })} placeholder="Email" />
{errors.email && <p style={{color: 'red'}}>{errors.email.message}</p>}
</div>
<div>
<input
type="password"
{...register('password', { required: 'Hasło wymagane' })}
placeholder="Hasło"
/>
{errors.password && <p style={{color: 'red'}}>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wysyłanie...' : 'Zarejestruj się'}
</button>
</form>
);
}
Ten formularz obsługuje wszystkie scenariusze: błędy walidacji, błędy z API, błędy sieci!
💪 Ćwiczenie 7: Formularz z symulacją błędów API
Stwórz formularz logowania z obsługą błędów:
Pola: email, hasło
Symuluj odpowiedzi API:
Jeśli email = "admin@test.com" i hasło = "admin123" → SUKCES
Jeśli email nie istnieje → błąd na polu email: "Konto nie istnieje"
Jeśli hasło błędne → błąd na polu hasło: "Nieprawidłowe hasło"
Jeśli email = "blocked@test.com" → błąd globalny: "Konto zablokowane"
Użyj setError do pokazania odpowiednich błędów
Pokaż komunikat "Zalogowano pomyślnie!" przy sukcesie
Praktyczny przykład: Kompletny formularz rejestracji
🔍 Co zawiera kompletny formularz produkcyjny?
Prawdziwy formularz w aplikacji musi mieć:
✅ Walidację (client + server)
✅ Obsługę błędów (pola + globalne)
✅ Stan ładowania (disabled button, spinner)
✅ Komunikaty sukcesu/błędu
✅ TypeScript (type safety)
✅ Czytelny kod
Poniżej zobaczysz pełny przykład z wszystkimi elementami!
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// Schema walidacji
const registrationSchema = z.object({
username: z.string()
.min(3, 'Min 3 znaki')
.max(20, 'Max 20 znaków')
.regex(/^[a-zA-Z0-9_]+$/, 'Tylko litery, cyfry i podkreślnik'),
email: z.string().email('Nieprawidłowy email'),
password: z.string()
.min(8, 'Min 8 znaków')
.regex(/[A-Z]/, 'Musi zawierać wielką literę')
.regex(/[a-z]/, 'Musi zawierać małą literę')
.regex(/[0-9]/, 'Musi zawierać cyfrę'),
confirmPassword: z.string(),
birthDate: z.string()
.transform((str) => new Date(str))
.refine((date) => {
const age = new Date().getFullYear() - date.getFullYear();
return age >= 18;
}, 'Musisz mieć min 18 lat'),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: 'Musisz zaakceptować regulamin'
})
}).refine((data) => data.password === data.confirmPassword, {
message: 'Hasła muszą być takie same',
path: ['confirmPassword']
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
function RegistrationForm() {
const [submitSuccess, setSubmitSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
reset,
setError
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: 'onChange' // Waliduj podczas wpisywania
});
async function onSubmit(data: RegistrationFormData) {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
// API zwróciło błędy walidacji
if (error.fieldErrors) {
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as keyof RegistrationFormData, {
type: 'manual',
message: message as string
});
});
return;
}
throw new Error(error.message || 'Registration failed');
}
// Sukces!
setSubmitSuccess(true);
reset();
} catch (error) {
setError('root', {
type: 'manual',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
if (submitSuccess) {
return (
<div className="success-message">
<h2>Rejestracja udana!</h2>
<p>Sprawdź swoją skrzynkę email aby aktywować konto.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="registration-form">
<h2>Rejestracja</h2>
{errors.root && (
<div className="error-alert">
{errors.root.message}
</div>
)}
<div className="form-group">
<label htmlFor="username">Login</label>
<input
id="username"
{...register('username')}
className={errors.username ? 'error' : ''}
/>
{errors.username && (
<span className="error-text">{errors.username.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email')}
className={errors.email ? 'error' : ''}
/>
{errors.email && (
<span className="error-text">{errors.email.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="password">Hasło</label>
<input
id="password"
type="password"
{...register('password')}
className={errors.password ? 'error' : ''}
/>
{errors.password && (
<span className="error-text">{errors.password.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Powtórz hasło</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
className={errors.confirmPassword ? 'error' : ''}
/>
{errors.confirmPassword && (
<span className="error-text">{errors.confirmPassword.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="birthDate">Data urodzenia</label>
<input
id="birthDate"
type="date"
{...register('birthDate')}
className={errors.birthDate ? 'error' : ''}
/>
{errors.birthDate && (
<span className="error-text">{errors.birthDate.message}</span>
)}
</div>
<div className="form-group checkbox">
<label>
<input
type="checkbox"
{...register('agreeToTerms')}
/>
Akceptuję regulamin
</label>
{errors.agreeToTerms && (
<span className="error-text">{errors.agreeToTerms.message}</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !isValid}
className="submit-button"
>
{isSubmitting ? 'Rejestrowanie...' : 'Zarejestruj się'}
</button>
</form>
);
}
🔍 Analiza krok po kroku:
Schema Zod - wszystkie reguły walidacji w jednym miejscu
mode: 'onChange' - walidacja podczas pisania (dobry UX)
isSubmitting - blokuje przycisk podczas wysyłania (zapobiega wielokrotnym kliknięciom)
isValid - przycisk aktywny tylko gdy formularz poprawny
setError('root', ...) - globalny błąd nad formularzem
setError(field, ...) - błędy przy konkretnych polach z API
submitSuccess - pokazuje ekran sukcesu po rejestracji
reset() - czyści formularz po sukcesie
🌟 CSS dla tego formularza:
.registration-form {
max-width: 500px;
margin: 0 auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #333;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.form-group input.error {
border-color: #dc3545;
background-color: #fff5f5;
}
.error-text {
color: #dc3545;
font-size: 14px;
display: block;
margin-top: 5px;
}
.error-alert {
background-color: #fee;
border: 1px solid #dc3545;
color: #dc3545;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
.submit-button {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.submit-button:hover:not(:disabled) {
background-color: #0056b3;
}
.submit-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.success-message {
text-align: center;
padding: 40px;
background: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 8px;
color: #155724;
}
Best Practices – 7 złotych zasad
🔍 Dlaczego Best Practices są ważne?
Best Practices to "sprawdzone receptury" wypracowane przez tysiące programistów. Stosując je unikniesz typowych błędów i napiszesz lepszy kod!
❌ Bez TypeScript - łatwo o błąd
const form = useForm();
form.setValue('emial', 'test@test.com'); // Literówka!
✅ Z TypeScript - błąd wykryty od razu
const form = useForm<FormData>();
form.setValue('emial', 'test@test.com'); // ❌ Error!
🔍 Dlaczego TypeScript?
Wyłapuje literówki w nazwach pól
Podpowiada dostępne pola (autocomplete)
Gwarantuje poprawne typy wartości
Ułatwia refaktoryzację (zmiana nazwy pola)
❌ Inline validation - ciężko utrzymać
<input {...register('email', {
required: 'Email required',
pattern: { value: /.../, message: '...' },
validate: { ... }
})} />
✅ Zod - czytelne, reużywalne
const schema = z.object({
email: z.string().email('Invalid email')
});
const form = useForm({ resolver: zodResolver(schema) });
// ❌ Default: 'onSubmit' - błędy dopiero po kliknięciu
const form = useForm();
// ✅ Waliduj gdy użytkownik opuszcza pole
const form = useForm({ mode: 'onBlur' });
// ✅ Lub podczas pisania (lepszy UX, ale więcej re-renderów)
const form = useForm({ mode: 'onChange' });
🔍 Który mode wybrać?
onSubmit - najszybszy, ale błędy dopiero po kliknięciu "Wyślij"
onBlur - dobry kompromis, waliduje gdy klikasz poza pole
onChange - najlepszy UX, pokazuje błędy natychmiast (ale wolniejsze dla dużych formularzy)
Najlepsza kombinacja:
const form = useForm({
mode: 'onSubmit', // Pierwsza walidacja dopiero po submit
reValidateMode: 'onChange' // Potem waliduj na bieżąco
});
❌ Wyciąganie w TSX - nieczytelne
return (
<button disabled={!formState.isValid || formState.isSubmitting}>
{formState.isSubmitting ? 'Loading...' : 'Submit'}
</button>
);
✅ Destructure na początku
const { formState: { errors, isValid, isSubmitting } } = useForm();
return (
<button disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Loading...' : 'Submit'}
</button>
);
❌ Watch wszystko - wolne
const allValues = watch();
✅ Watch tylko potrzebne pola
const email = watch('email');
const password = watch('password');
🔍 Dlaczego?
watch() powoduje re-render przy KAŻDEJ zmianie w formularzu. Jeśli masz 20 pól i watch'ujesz wszystkie, komponent renderuje się 20 razy podczas wypełniania! Używaj tylko gdy naprawdę potrzebujesz wartości na żywo.
// ❌ Nie próbuj użyć register z custom componentami
<ReactSelect {...register('country')} /> // Nie zadziała!
// ✅ Używaj Controller
<Controller
name="country"
control={control}
render={({ field }) => <ReactSelect {...field} />}
/>
function EditUserForm({ userId }: { userId: number }) {
const { register, reset } = useForm<User>();
useEffect(() => {
async function loadUser() {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
reset(user); // Ustaw wszystkie wartości naraz
}
loadUser();
}, [userId, reset]);
return (
<form>
<input {...register('name')} />
<input {...register('email')} />
</form>
);
}
🔍 Dlaczego reset() a nie setValue()?
reset(data) ustawia WSZYSTKIE pola naraz i dodatkowo:
Resetuje isDirty do false (formularz jest "czysty")
Czyści błędy
Ustawia nowe defaultValues
setValue('name', value) ustawia tylko jedno pole i nie resetuje isDirty - użyj gdy zmieniasz wartość ręcznie z kodu.
Integracja z UI libraries
import { TextField } from '@mui/material';
import { Controller } from 'react-hook-form';
function MuiForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="email"
control={control}
defaultValue=""
rules={{ required: 'Email required' }}
render={({ field, fieldState: { error } }) => (
<TextField
{...field}
label="Email"
error={!!error}
helperText={error?.message}
fullWidth
/>
)}
/>
</form>
);
}
import { Input, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react';
function ChakraForm() {
const { register, formState: { errors }, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input {...register('email', { required: 'Email required' })} />
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
</form>
);
}
🔍 UI Libraries - kiedy używać Controller?
Zwykłe inputy bibliotek UI - często można użyć {...register()} bezpośrednio (Chakra UI)
Skomplikowane komponenty - używaj Controller (Material-UI TextField, React Select, Date Pickers)
Reguła: jeśli komponent przyjmuje value i onChange inaczej niż natywny input - użyj Controller.
Performance tips
✅ Uncontrolled - szybkie, nie powoduje re-renderów
<input {...register('email')} />
❌ Controlled - wolniejsze, re-renderuje przy każdej zmianie
const email = watch('email');
<input value={email} onChange={...} />
// Waliduj dopiero po submit
const form = useForm({ mode: 'onSubmit' });
// Potem przełącz na onChange dla lepszego UX
const form = useForm({
mode: 'onSubmit',
reValidateMode: 'onChange' // Po pierwszym błędzie waliduj na bieżąco
});
const form = useForm({
shouldUnregister: true // Usuń wartości pól gdy są unmounted
});
🔍 Kiedy używać shouldUnregister: true?
Gdy masz warunkowe pola (pokazujesz/ukrywasz w zależności od wyboru):
const deliveryMethod = watch('deliveryMethod');
return (
<>
<select {...register('deliveryMethod')}>
<option value="pickup">Odbiór osobisty</option>
<option value="courier">Kurier</option>
</select>
{/* Te pola znikają gdy wybierzesz "pickup" */}
{deliveryMethod === 'courier' && (
<>
<input {...register('address')} />
<input {...register('city')} />
</>
)}
</>
);
Z shouldUnregister: true pola address i city zostaną USUNIĘTE z formularza gdy ukryte. Bez tego pozostaną w danych!
Testowanie formularzy
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RegistrationForm from './RegistrationForm';
describe('RegistrationForm', () => {
test('shows validation errors on invalid submit', async () => {
render(<RegistrationForm />);
const submitButton = screen.getByRole('button', { name: /register/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/email required/i)).toBeInTheDocument();
expect(screen.getByText(/password required/i)).toBeInTheDocument();
});
});
test('submits form with valid data', async () => {
const onSubmit = jest.fn();
render(<RegistrationForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'Password123');
fireEvent.click(screen.getByRole('button', { name: /register/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'Password123'
});
});
});
test('shows error for invalid email', async () => {
render(<RegistrationForm />);
const emailInput = screen.getByLabelText(/email/i);
await userEvent.type(emailInput, 'invalid-email');
fireEvent.blur(emailInput);
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
});
🔍 Testowanie formularzy - podstawy:
userEvent.type() - symuluje wpisywanie tekstu (lepsze niż fireEvent)
fireEvent.blur() - symuluje kliknięcie poza pole (dla onBlur validation)
waitFor() - czeka aż coś się pojawi (dla asynchronicznych operacji)
screen.getByRole() - szuka elementu po roli (button, textbox...)
screen.getByLabelText() - szuka inputa po jego label
Podsumowanie
To był bardzo praktyczny wpis! Nauczyliśmy się:
✅ React Hook Form podstawy – useForm , register , handleSubmit , formState
✅ Walidacja – wbudowana, custom functions , async validation
✅ Integracja z Zod – type-safe schema validation
✅ Nested objects i arrays – useFieldArray , dynamiczne pola
✅ Controlled components – Controller dla custom inputs
✅ Watch, setValue, reset – manipulacja formularzem
✅ Error handling – wyświetlanie, programowe ustawianie
✅ Kompletny przykład – formularz rejestracji z wszystkim
✅ Best Practices – 7 złotych zasad
✅ Integracja z UI libraries – Material-UI , Chakra UI
✅ Performance – optymalizacja re-renderów
✅ Testowanie – przykłady testów
React Hook Form + Zod to najlepsza kombinacja dla formularzy w React . Teraz możesz zbudować każdy formularz – od prostego logowania po złożony wizard !
W kolejnym wpisie poznamy Zustand – minimalistyczną bibliotekę do globalnego state management . Prostszą od Redux , ale równie potężną!
Stwórz formularz zamówienia:
Dane osobowe (imię, nazwisko, email, telefon)
Adres dostawy (ulica, miasto, kod pocztowy, kraj)
Produkty (dynamiczna lista z useFieldArray )
Metoda płatności (select )
Walidacja z Zod
Suma zamówienia obliczana dynamicznie (watch )
🎯 BONUS: Wielki projekt końcowy
Połącz WSZYSTKO czego się nauczyłeś i stwórz Multi-step Wizard (formularz wielokrokowy) dla wypożyczalni samochodów:
Krok 1: Wybór samochodu
Kategoria (ekonomiczny, premium, SUV) - radio buttons
Data odbioru i zwrotu - date inputs (zwrot > odbiór)
Miejsce odbioru i zwrotu - select
Krok 2: Dodatki
Lista dodatkowych opcji (checkbox): GPS, fotelik dziecięcy, dodatkowy kierowca
Każda opcja ma cenę
Ubezpieczenie - select (podstawowe, pełne, premium)
Krok 3: Dane kierowcy
Imię, nazwisko, email, telefon
Numer prawa jazdy
Data urodzenia (min 23 lata)
Krok 4: Podsumowanie i płatność
Wyświetl wszystkie wybrane opcje
Oblicz całkowity koszt
Dane karty kredytowej (numer, data ważności, CVV)
Checkbox: "Akceptuję regulamin"
Wymagania techniczne:
✅ Walidacja z Zod dla każdego kroku
✅ TypeScript dla wszystkich danych
✅ useFieldArray dla listy dodatków
✅ watch do obliczania ceny na żywo
✅ Przyciski "Dalej" / "Wstecz" / "Wyślij"
✅ "Dalej" zablokowany dopóki krok nie jest validny
✅ Progress bar pokazujący aktualny krok (1/4, 2/4...)
✅ Możliwość edycji poprzednich kroków
✅ Po wysłaniu: ekran potwierdzenia z numerem rezerwacji
To ambitne zadanie, ale łączy WSZYSTKIE techniki z artykułu. Powodzenia! 🚗💨