Jak tworzyć formularze bez frustracji: React Hook Form i walidacja krok po kroku

0
8
Rate this post

Nawigacja:

Dlaczego formularze w React tak łatwo zamieniają się w koszmar

Źródła frustracji: stan, walidacja, UX i obsługa błędów

Formularz w React rzadko psuje się „spektakularnie”. Zwykle degeneruje się powoli: tu zniknie błąd walidacji, tam pole nie zresetuje się po wylogowaniu, gdzie indziej pojawi się nieoczywista pętla renderów. Z wierzchu wszystko wygląda prosto – kilka inputów, przycisk „Wyślij” – ale pod spodem zderzają się trzy trudne obszary: zarządzanie stanem, logika walidacji oraz UX obsługi błędów.

Przy kilku polach da się to jeszcze „przepchnąć” za pomocą paru useState i ręcznych warunków. Problemy zaczynają się, gdy formularz rośnie: pojawiają się sekcje warunkowe, walidacja zależna od innych pól, integracje z backendem, a do tego oczekiwania zespołu produktowego dotyczące jakości UX. Wtedy każdy kolejny hotfix robi kod bardziej kruchym i trudniejszym do utrzymania.

Najczęstsze konsekwencje to:

  • stan formularza rozlany po wielu hookach i komponentach, trudny do prześledzenia,
  • walidacja zdublowana między frontendem i backendem, niespójna lub sprzeczna,
  • losowe „znikanie” wartości przy przełączeniu zakładek lub ukryciu sekcji,
  • reakcje użytkowników: „klikam, nic się nie dzieje”, bo błędy są niejasne lub niewidoczne.

To nie jest kwestia pojedynczej biblioteki, tylko złożoności samego problemu. React Hook Form porządkuje wiele z tych elementów, ale nie zdejmuje odpowiedzialności za przemyślaną architekturę.

Różnica między formularzem z tutoriala a realnym przypadkiem biznesowym

Formularz logowania z dwóch pól to typowy przykład edukacyjny. Łatwo go ogarnąć nawet dowolnym „naiwnym” podejściem. W realnych projektach pojawiają się natomiast formularze:

  • rejestracji z wieloma krokami, checkboxami zgód, walidacją hasła i potwierdzenia,
  • koszyka lub zamówienia z listą pozycji, rabatami i adresami dostawy,
  • panelu administracyjnego z dziesiątkami pól, filtrami i zależnościami między sekcjami.

W takich formularzach nie wystarczy „jakoś” zapisać wartości. Trzeba zapewnić:

  • spójną walidację (lokalną i globalną),
  • czytelne komunikaty dla użytkownika,
  • łatwe rozszerzanie i refaktorowanie przez zespół,
  • przyzwoitą wydajność – bez odświeżania całego drzewa UI przy każdym znaku.

Tu wychodzi, jak bardzo różni się przykładowy kod z dokumentacji od codziennej pracy. React Hook Form jest projektowany z myślą o tych trudniejszych przypadkach, ale jeśli podejść do niego „jak do Formika” lub „jak do zwykłego useState”, można narobić sobie dodatkowych problemów.

Problemy typowych podejść: ręczny stan i brak spójnej walidacji

Najpopularniejszy antywzorzec w React to formularz oparty na jednym wielkim useState i obsłudze onChange dla każdego pola. Z czasem rosną tam:

  • rozbudowane obiekty: { name, email, address, ... },
  • sklejane walidacje typu if (!email) setError(...),
  • dublowanie logiki w różnych efektach i callbackach.

Kod zaczyna przypominać rejestr wyjątków zamiast stabilnego mechanizmu. Częstym dodatkiem jest ręczna obsługa „touched”, „dirty”, „submitting”, „isValid”, co kończy się lawiną stanów i warunków.

Brak spójnej walidacji to osobny problem. Walidacja implementowana ad hoc w różnych miejscach (komponent, helper, hook, backend) sprawia, że nie da się łatwo odpowiedzieć na pytanie: „gdzie dokładnie jest logika, która decyduje, czy formularz przejdzie?”. Przy zmianie wymagań rośnie ryzyko, że coś zostanie przeoczone – szczególnie w polach zależnych od siebie.

Gdzie React Hook Form naprawdę pomaga, a gdzie nie ma magii

React Hook Form rozwiązuje kilka konkretnych klas problemów:

  • centralizuje stan formularza w jednym hooku useForm,
  • opiera się na niekontrolowanych polach, ograniczając liczbę rerenderów,
  • daje spójną strukturę błędów (formState.errors),
  • umożliwia integrację z bibliotekami walidacji (Yup, Zod) przez resolver,
  • obsługuje dynamiczne pola (useFieldArray) bez ręcznego pilnowania kluczy.

Nie zrobi jednak za nikogo:

  • przemyślanej architektury komponentów,
  • dobrego UX błędów i komunikatów,
  • spójności między walidacją frontendu i backendu,
  • bezpiecznej obsługi błędów sieciowych i edge case’ów.

Biblioteka usuwa część „ciężaru mechanicznego” (rejestrowanie pól, śledzenie touched/dirty, kontrola rerenderów), ale dalej trzeba projektować logikę i strukturę formularza świadomie.

Jak działa React Hook Form pod spodem i co to zmienia

Filozofia: niekontrolowane pola i minimalizacja rerenderów

React Hook Form bazuje na założeniu, że React nie musi wiedzieć o każdej zmianie w polu input. Zamiast typowych kontrolowanych komponentów (value + onChange), stosuje podejście niekontrolowane oparte o natywne API formularzy HTML. Główne skutki:

  • wartości pól są przetrzymywane w DOM i wewnętrznej strukturze RHF, a nie w stanie Reacta,
  • przy wpisywaniu tekstu nie ma ciągłego aktualizowania rodzica i całego drzewa UI,
  • React otrzymuje sygnały głównie przy zdarzeniach typu blur, submit, ewentualnie walidacja w wybranym trybie.

Input jest „zarejestrowany” w formularzu za pomocą funkcji register, która łączy pole z wewnętrznym storem RHF i ustawia odpowiednie atrybuty. To jest główna różnica filozoficzna względem bibliotek typu Formik, które bardziej polegają na kontrolowanym stanie.

Kluczowe hooki: useForm, register, handleSubmit, formState

Rdzeń React Hook Form to hook useForm. Wywołanie:

const {
  register,
  handleSubmit,
  formState,
  reset,
  watch,
  control
} = useForm({ defaultValues, mode: 'onSubmit' });

Kluczowe elementy:

  • register – funkcja, którą wywołujesz dla każdego „zwykłego” pola. Zwraca atrybuty do wstrzyknięcia w input (np. ref, onChange, onBlur), rejestruje nazwę pola w formularzu i pozwala zdefiniować podstawową walidację.
  • handleSubmit – wrapper na onSubmit formularza. Najpierw uruchamia walidację, a dopiero potem woła handler z poprawnymi danymi. Drugi opcjonalny parametr obsłuży błędy.
  • formState – obiekt ze stanem: errors, isDirty, isValid, isSubmitting, touchedFields itd. RHF dba o to, by aktualizacje były możliwie precyzyjne i nie wymuszały zbędnych renderów.
  • reset – zmiana wartości formularza (np. po ładowaniu danych z API) oraz czyszczenie błędów.
  • watch – obserwacja aktualnych wartości pól (pojedynczych lub wszystkich), z opcją nasłuchiwania w czasie rzeczywistym.
  • control – obiekt przekazywany do bardziej zaawansowanych komponentów, m.in. Controller i useFieldArray.

Co React Hook Form robi automatycznie, a co nadal wymaga uwagi

Automatyzacje, które realnie odciążają projektanta formularza:

  • śledzenie stanu pól (touched, dirty) bez ręcznego zakładania useState dla każdego inputa,
  • zarządzanie błędami w jednym miejscu: formState.errors,
  • aktualizacja tylko tych komponentów, które faktycznie potrzebują zaktualizowanego stanu (np. konkretne błędy),
  • integracja z zewnętrzną walidacją przez resolver, bez własnej „klejonej” logiki walidacyjnej.

Rzeczy, które dalej trzeba zaplanować samodzielnie:

  • architekturę komponentów (czy formularz jest w jednym komponencie, czy podzielony na sekcje),
  • strategię wyświetlania błędów (po blur, po submit, hybrydowo),
  • spójność komunikatów z backendem (łącznie z obsługą błędów serwerowych),
  • logikę warunkowego renderowania pól i resetowania ich wartości.

Automatyka RHF nie zastąpi odpowiedzi na pytanie „jak ma działać formularz biznesowo”. Dobrze jednak odciąża w warstwie technicznej, gdzie łatwo popełnić powtarzalne i kosztowne błędy.

Kiedy podejście RHF ma sens, a kiedy wystarczy zwykły useState

Nie każde pole tekstowe wymaga React Hook Form. Prosty formularz z jednym inputem typu „wyszukaj” lub filtrem może być zupełnie spokojnie obsłużony przez useState i onChange. Jeśli nie potrzebujesz:

  • walidacji (lub jest bardzo symboliczna),
  • śledzenia touched/dirty,
  • obsługi błędów w UI,
  • dynamicznej listy pól,

to wprowadzenie cięższej abstrakcji niekoniecznie się opłaca.

React Hook Form zaczyna naprawdę zwracać inwestycję, gdy:

  • formularz ma więcej niż kilka pól i rośnie,
  • wymagana jest rozbudowana walidacja, najlepiej opisana jednym schematem,
  • trzeba utrzymać dobrą wydajność na słabszych urządzeniach,
  • formularz ma żyć latami i będzie dotykany przez wielu programistów.

Przy jednym inputcie useState jest szybszy w implementacji i bardziej czytelny. Przy rozbudowanym panelu admina – najczęściej odwrotnie.

Pierwszy formularz bez magii: konfiguracja i prosty przykład

Minimalna konfiguracja useForm: defaultValues i tryb walidacji

Startowe wywołanie useForm powinno uwzględniać przede wszystkim dwie rzeczy: domyślne wartości pól oraz tryb walidacji:

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting }
} = useForm({
  defaultValues: {
    email: '',
    password: ''
  },
  mode: 'onSubmit' // inne: 'onBlur', 'onChange', 'all'
});

defaultValues określa początkowy stan formularza. Pomijanie tego parametru to proszenie się o dziwne zachowania przy resetowaniu formularza lub ładowaniu danych z API.

mode kontroluje, kiedy odpalana jest walidacja:

  • 'onSubmit' – klasyczny scenariusz: błąd pojawia się po pierwszym submitcie, potem także na onChange/onBlur dla błędnych pól.
  • 'onBlur' – walidacja przy wyjściu z pola; dobre dla spokojniejszych formularzy, bez „migającej czerwieni” przy każdym znaku.
  • 'onChange' – bardzo responsywna walidacja, ale przy złożonych regułach i słabszych urządzeniach może być ciężka.
  • 'all' – walidacja przy każdym możliwym zdarzeniu; nadaje się raczej do specyficznych zastosowań.

W praktyce dominują 'onSubmit' albo hybryda: reValidateMode ustawione inaczej niż mode (np. pierwszy raz po submitcie, później na blur).

Rejestracja pól: register, name i powiązanie z formularzem

Najprostsze pole w React Hook Form wygląda tak:

<input
  type="email"
  placeholder="Email"
  {...register('email', { required: 'Email jest wymagany' })}
/>
{errors.email && <span>{errors.email.message}</span>}

Wywołanie register('email') robi kilka rzeczy naraz:

  • rejestruje pole o nazwie email w kontekście formularza,
  • ustawia wewnętrzny ref do tego inputa,
  • dokleja obsługę zdarzeń (onChange, onBlur) potrzebną do walidacji i śledzenia stanu,
  • opcjonalnie podłącza reguły walidacji (drugi argument).

Warunek konieczny: name musi być stabilny i zgodny z tym, czego używasz w defaultValues. Jeśli zmienisz nazwę pola i zapomnisz zaktualizować defaultValues, pojawią się niespójności, np. brak resetowania wartości czy brak walidacji.

Wbudowana walidacja: required, minLength, pattern

React Hook Form pozwala definiować podstawowe reguły walidacji bez dodatkowych bibliotek. Przykład:

Rozszerzone zasady: minLength, maxLength, pattern i własne funkcje

Podstawowe reguły walidacji mogą załatwić dużą część typowych przypadków, pod warunkiem sensownego użycia. Przykładowo:

<input
  type="password"
  placeholder="Hasło"
  {...register('password', {
    required: 'Hasło jest wymagane',
    minLength: {
      value: 8,
      message: 'Hasło musi mieć co najmniej 8 znaków'
    }
  })}
/>

<input
  type="text"
  placeholder="Imię"
  {...register('firstName', {
    maxLength: {
      value: 50,
      message: 'Imię jest zbyt długie'
    }
  })}
/>

<input
  type="email"
  placeholder="Email"
  {...register('email', {
    required: 'Email jest wymagany',
    pattern: {
      value: /^[^s@]+@[^s@]+.[^s@]+$/,
      message: 'Podaj poprawny adres email'
    }
  })}
/>

Najczęstsza pułapka: wrzucanie zbyt agresywnych reguł już na starcie (np. pattern, który rozwala się na realnych danych użytkowników). Najpierw prosty required i długość, dopiero później wyrafinowane regexy.

Dla nietypowych wymagań można użyć własnej funkcji w validate:

<input
  type="text"
  placeholder="Nazwa użytkownika"
  {...register('username', {
    required: 'Nazwa użytkownika jest wymagana',
    validate: (value) => {
      if (value.includes(' ')) {
        return 'Nazwa użytkownika nie może zawierać spacji';
      }
      if (!/^[a-zA-Z0-9_.-]+$/.test(value)) {
        return 'Dozwolone są tylko litery, cyfry oraz znaki ._-';
      }
      return true;
    }
  })}
/>

W validate zwrot true oznacza brak błędu, tekst – komunikat błędu. Przy bardziej rozbudowanej logice łatwo wpaść w nieczytelny „if-hell”, więc zwykle lepiej wydzielić funkcję poza komponent albo od razu przejść na schemat walidacji.

Asynchroniczna walidacja: kiedy się przydaje, a kiedy przeszkadza

React Hook Form dopuszcza walidację asynchroniczną – funkcja validate może zwrócić Promise. Typowy przykład to sprawdzanie unikalności nazwy użytkownika na serwerze:

async function checkUsernameAvailable(username) {
  const res = await fetch(`/api/users/check?username=${encodeURIComponent(username)}`);
  if (!res.ok) return false;
  const data = await res.json();
  return data.available;
}

<input
  {...register('username', {
    required: 'Nazwa użytkownika jest wymagana',
    validate: async (value) => {
      const available = await checkUsernameAvailable(value);
      return available || 'Ta nazwa użytkownika jest już zajęta';
    }
  })}
/>

Kuszące jest walidowanie w taki sposób przy każdym znaku, ale zwykle kończy się to spamem zapytań do API i problemami z wyścigami (wyniki starego zapytania nadpisują nowe). Rozsądniej uruchamiać takie sprawdzenia:

  • po blur (użytkownik skończył pisać),
  • lub dopiero przy submitcie, a wcześniej informować, że unikalność będzie sprawdzana na końcu.

Jeśli walidacja serwerowa jest krytyczna, i tak trzeba ją powtórzyć po stronie backendu – walidacja w formularzu ma charakter pomocniczy, nie gwarancyjny.

Zbliżenie ekranu z kodem HTML i CSS podczas pracy nad formularzem
Źródło: Pexels | Autor: Bibek ghosh

Walidacja krok po kroku: od rules do schematów (Yup / Zod)

Kiedy wbudowane rules przestają wystarczać

Proste reguły w register rozwiązują sporo problemów, ale przy rozbudowanych formularzach szybko pojawiają się ograniczenia:

  • wiele pól zależnych od siebie (daty od–do, potwierdzenie hasła),
  • logika warunkowa („jeśli zaznaczone X, to pole Y jest wymagane”),
  • powtarzalne schematy (adres, dane firmy) używane w kilku miejscach,
  • konieczność utrzymania spójności z backendem, który ma własny schemat walidacji.

W takiej sytuacji proste rules zaczynają mutować w nieprzejrzystą siatkę warunków. To dobry moment, by przenieść walidację do dedykowanego schematu i skleić go z React Hook Form przez resolver.

Integracja z Yup: klasyczny, ale nie jedyny wybór

Yup jest jednym z najpopularniejszych rozwiązań, choć nie jest jedyną opcją. Podstawowa integracja wygląda tak:

import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';

const schema = yup.object({
  email: yup
    .string()
    .required('Email jest wymagany')
    .email('Podaj poprawny email'),
  password: yup
    .string()
    .required('Hasło jest wymagane')
    .min(8, 'Hasło musi mieć co najmniej 8 znaków'),
  confirmPassword: yup
    .string()
    .oneOf([yup.ref('password')], 'Hasła muszą być takie same')
}).required();

const {
  register,
  handleSubmit,
  formState: { errors }
} = useForm({
  resolver: yupResolver(schema),
  defaultValues: {
    email: '',
    password: '',
    confirmPassword: ''
  }
});

Dzięki resolverowi cała logika walidacji jest poza komponentem, a sam formularz staje się prostszy. Z drugiej strony, dochodzi dodatkowa warstwa abstrakcji – trzeba pilnować zgodności nazw pól w schema z register i defaultValues. Jeśli nazwa się rozjedzie, błędy walidacji będą tajemniczo „znikać”.

Zod i TypeScript: spójność typu + walidacja

Przy projektach w TypeScript przewaga Zoda robi się bardziej widoczna. Schemat służy jednocześnie jako walidacja w runtime i źródło typów w czasie kompilacji:

import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const loginSchema = z.object({
  email: z
    .string()
    .min(1, 'Email jest wymagany')
    .email('Podaj poprawny email'),
  password: z
    .string()
    .min(8, 'Hasło musi mieć co najmniej 8 znaków')
});

type LoginFormValues = z.infer<typeof loginSchema>;

const {
  register,
  handleSubmit,
  formState: { errors }
} = useForm<LoginFormValues>({
  resolver: zodResolver(loginSchema),
  defaultValues: {
    email: '',
    password: ''
  }
});

Tu zyskuje się jedną ważną rzecz: jeśli w kodzie formularza spróbujesz odwołać się do register('unknownField'), kompilator zgłosi błąd. To realnie obcina kategorię bugów typu literówka w nazwie pola.

Walidacja krzyżowa: pola zależne od siebie

Proste rules działają per pole, a przypadki „cross-field” wymagają dodatkowego kroku. Przykład w Zod z datą od–do:

const dateRangeSchema = z
  .object({
    startDate: z.string().min(1, 'Data początkowa jest wymagana'),
    endDate: z.string().min(1, 'Data końcowa jest wymagana')
  })
  .refine(
    (data) => new Date(data.startDate) <= new Date(data.endDate),
    {
      message: 'Data końcowa nie może być wcześniejsza niż początkowa',
      path: ['endDate'] // błąd przypisany do konkretnego pola
    }
  );

Analogicznie w Yup można użyć test z dostępem do this.parent. Tego typu reguły powinny być raczej w jednym miejscu (schemat) niż rozsmarowane po komponentach w validate, inaczej debugowanie po roku od wdrożenia staje się sportem ekstremalnym.

Obsługa błędów i komunikatów: jak nie zalać użytkownika czerwienią

Strategie wyświetlania błędów: po submit, po blur, hybrydowo

Decyzja, kiedy pokazywać błędy, ma większy wpływ na odbiór formularza niż sam dobór biblioteki. Typowe strategie:

  • tylko po submitcie – dobry wybór dla prostych formularzy i mniej technicznych użytkowników; minimalizuje „miganie czerwienią”, ale bywa frustrujący przy długich formularzach (użytkownik dowiaduje się o błędach dopiero na końcu),
  • po blur – użytkownik dostaje feedback, gdy skończy pisać w polu; zwykle rozsądny kompromis dla większości scenariuszy,
  • po zmianie (onChange) – przydatne przy krótkich formularzach lub polach z natychmiastowym feedbackiem (np. siła hasła), ale przy zbyt agresywnej walidacji szybko męczy.

React Hook Form daje nad tym kontrolę przez mode i reValidateMode:

const form = useForm({
  mode: 'onSubmit',
  reValidateMode: 'onBlur'
});

Taki układ często się sprawdza: błędy pojawiają się dopiero po pierwszym submitcie, a potem aktualizują przy blurze, bez ciągłego walidowania przy każdym znaku.

Lokalne komunikaty pod polami vs. ogólne podsumowania błędów

Najprostszy wzorzec to komunikat bezpośrednio pod polem:

{errors.email && <p className="error">{errors.email.message}</p>}

Przy krótkich formularzach to wystarcza. Przy dłuższych dobrze jest dodać ogólną sekcję błędów u góry, by użytkownik od razu widział, że formularz jest niespójny:

const {
  formState: { errors, isSubmitted }
} = useFormContext(); // jeśli używasz FormProvider

{isSubmitted && Object.keys(errors).length > 0 && (
  <div className="error-summary">
    <p>Popraw zaznaczone pola:</p>
    <ul>
      {Object.entries(errors).map(([name, error]) => (
        <li key={name}>{error.message}</li>
      ))}
    </ul>
  </div>
)}

Trzeba tylko pilnować, by komunikaty w podsumowaniu i pod polami nie były sprzeczne. Rozsądniej wyświetlać ten sam tekst w obu miejscach niż wymyślać różne wersje.

Błędy serwerowe: integracja z RHF bez chaosu

W pewnym momencie walidacja po stronie klienta i tak przestaje wystarczać. Backend może odrzucić dane z powodów, których front nie przewidział (np. reguły biznesowe zależne od stanu systemu). Typowy schemat pracy z błędami serwerowymi:

const {
  register,
  handleSubmit,
  setError,
  formState: { errors }
} = useForm();

const onSubmit = async (data) => {
  try {
    await api.saveUser(data);
  } catch (err) {
    if (err.response?.status === 400 && err.response?.data?.fieldErrors) {
      // Błędy przypisane do konkretnych pól
      Object.entries(err.response.data.fieldErrors).forEach(
        ([field, message]) => {
          setError(field, { type: 'server', message });
        }
      );
    } else {
      // Ogólny błąd formularza
      setError('root', {
        type: 'server',
        message: 'Nie udało się zapisać danych. Spróbuj ponownie.'
      });
    }
  }
};

Potem wystarczy obsłużyć errors.root gdzieś nad formularzem:

{errors.root && <p className="error-global">{errors.root.message}</p>}

Z punktu widzenia utrzymania ważne jest, by backend zwracał błędy w spójnym formacie (np. mapa { field: message }), inaczej każda integracja skończy się własną, ręcznie szytą transformacją.

Praca z bardziej skomplikowanymi polami: selecty, checkboxy, datepickery, komponenty UI

Proste selecty i checkboxy z register

Dla natywnych elementów sprawa jest mało kontrowersyjna – w większości przypadków wystarczy register:

<select
  {...register('country', { required: 'Wybierz kraj' })}
  defaultValue=""
>
  <option value="" disabled>Wybierz kraj</option>
  <option value="pl">Polska</option>
  <option value="de">Niemcy</option>
</select>

<label>
  <input
    type="checkbox"
    {...register('acceptTerms', {
      required: 'Musisz zaakceptować regulamin'
    })}
  />
  Akceptuję regulamin
</label>

Natywne pola dobrze współpracują z RHF, dopóki nie wchodzą w grę własne komponenty UI, które nie przekazują ref dalej albo nie wystawiają typowego onChange.

Controller: pomost do komponentów zewnętrznych

Przy bibliotekach typu MUI, Chakra, Headless UI, React Select czy różnego rodzaju datepickerach zwykłe register przestaje wystarczać. Tu potrzebny jest Controller, który spina zewnętrzny komponent z wewnętrznym staniem formularza:

import { Controller, useForm } from 'react-hook-form';
import Select from 'react-select';

const { control } = useForm({
  defaultValues: {
    favouriteColor: null
  }
});

<Controller
  name="favouriteColor"
  control={control}
  rules={{ required: 'Wybierz kolor' }}
  render={({ field, fieldState }) => (
    <div>
      <Select
        {...field}
        options={[
          { value: 'red', label: 'Czerwony' },
          { value: 'blue', label: 'Niebieski' }
        ]}
        // React Select oczekuje value jako całego obiektu, nie tylko value
        value={field.value}
        onChange={(option) => field.onChange(option)}
      />
      {fieldState.error && (
        <p className="error">{fieldState.error.

Datepickery i komponenty z własnym stanem

Kompontenty typu datepicker są specyficzne: mają własny stan otwarcia, formatują daty, czasem przyjmują stringi, a czasem obiekty Date. RHF nie narzuca tu jednego wzorca – klucz to spójność formatu danych, który ostatecznie ląduje w formularzu.

import { Controller, useForm } from 'react-hook-form';
import DatePicker from 'react-datepicker';

const { control } = useForm({
  defaultValues: {
    birthday: null
  }
});

<Controller
  name="birthday"
  control={control}
  rules={{ required: 'Podaj datę urodzenia' }}
  render={({ field, fieldState }) => (
    <div>
      <DatePicker
        selected={field.value}
        onChange={(date) => field.onChange(date)}
        dateFormat="dd.MM.yyyy"
        placeholderText="Wybierz datę"
      />
      {fieldState.error && (
        <p className="error">{fieldState.error.message}</p>
      )}
    </div>
  )}
/>

Najbezpieczniej przechowywać w formularzu to, czego wymaga backend (np. string w ISO) i na styku z komponentem robić konwersję:

render={({ field, fieldState }) => {
  const valueAsDate = field.value ? new Date(field.value) : null;

  return (
    <div>
      <DatePicker
        selected={valueAsDate}
        onChange={(date) =>
          field.onChange(date ? date.toISOString() : null)
        }
      />
      {fieldState.error && (
        <p className="error">{fieldState.error.message}</p>
      )}
    </div>
  );
}}

Trzymanie różnych formatów (np. raz string, raz Date) zwykle kończy się trudnym do odtworzenia bugiem w najmniej oczekiwanym momencie, np. przy resecie formularza.

Integracja z bibliotekami UI: MUI / Chakra / Headless UI

Większość popularnych bibliotek dostarcza komponenty kontrolowane (przyjmują value i onChange), ale niekoniecznie wystawiają ref tak, jak chce tego register. Dlatego w praktyce z nimi też częściej używa się Controller.

import { Controller, useForm } from 'react-hook-form';
import TextField from '@mui/material/TextField';

const { control } = useForm({
  defaultValues: { email: '' }
});

<Controller
  name="email"
  control={control}
  rules={{
    required: 'Email jest wymagany',
    pattern: {
      value: /S+@S+.S+/,
      message: 'Podaj poprawny adres email'
    }
  }}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      label="Email"
      error={!!fieldState.error}
      helperText={fieldState.error?.message}
      fullWidth
    />
  )}
/>

Niektóre komponenty przy onChange zwracają całe zdarzenie, inne tylko wartość. Zdarza się też, że biblioteka trzyma wartość pod inną nazwą (selected, checked). Jeżeli Controller zaczyna wyglądać jak mały adapter, to zwykle jest to sygnał, że lepiej owinąć cudzy komponent własnym, wyspecjalizowanym wrapperem.

Własne wrappery na pola: „Field” jako warstwa tłumacząca

Przy większym projekcie powtarzanie tych samych wzorców (Controller + MUI TextField + errors) staje się nużące i podatne na rozjazdy. Lepszym rozwiązaniem jest własny, cienki komponent pola:

type TextFieldControlledProps = {
  name: string;
  label: string;
  control: Control<any>;
  rules?: RegisterOptions;
};

function TextFieldControlled({
  name,
  label,
  control,
  rules
}: TextFieldControlledProps) {
  return (
    <Controller
      name={name}
      control={control}
      rules={rules}
      render={({ field, fieldState }) => (
        <TextField
          {...field}
          label={label}
          error={!!fieldState.error}
          helperText={fieldState.error?.message}
          fullWidth
        />
      )}
    />
  );
}

To podejście ma kilka skutków ubocznych – pozytywnych. Zmiana biblioteki UI albo standardu komunikatów błędów wymaga podmiany jednego miejsca, a nie polowania po całej aplikacji. Minus: trzeba uważać, żeby ten poziom abstrakcji nie ukrył zbyt wiele (np. specyficznych propsów komponentu UI).

Pola „wielowartościowe”: grupy checkboxów, multi-select

Dane typu „lista zaznaczonych opcji” prowokują do trzymania w stanie formularza surowej tablicy stringów. Zwykle to wystarcza, ale trzeba się trzymać jednej konwencji od początku do końca.

const { register } = useForm({
  defaultValues: {
    interests: [] // string[]
  }
});

<label>
  <input
    type="checkbox"
    value="frontend"
    {...register('interests')}
  />
  Frontend
</label>

<label>
  <input
    type="checkbox"
    value="backend"
    {...register('interests')}
  />
  Backend
</label>

Tu RHF sam zbuduje tablicę wartości zaznaczonych checkboxów. Problem zaczyna się tam, gdzie backend oczekuje innego formatu (np. tablicy obiektów lub słownika). W takim przypadku lepiej od razu ustandaryzować kształt danych w onSubmit lub w resolverze, zamiast przepychać „pół-obiekty” przez wszystkie komponenty.

Dynamiczne formularze, Field Arrays i warunkowe renderowanie

Powtarzalne sekcje: useFieldArray jako podstawowe narzędzie

Adresy, członkowie zespołu, listy produktów – wszędzie tam powtarza się ten sam wzór: użytkownik może dodać dowolną liczbę sekcji. Wbudowany useFieldArray dobrze pokrywa takie przypadki, pod warunkiem, że trzyma się kilku zasad.

import { useForm, useFieldArray } from 'react-hook-form';

type FormValues = {
  users: { name: string; email: string }[];
};

const { control, register, handleSubmit } = useForm<FormValues>({
  defaultValues: {
    users: [{ name: '', email: '' }]
  }
});

const { fields, append, remove } = useFieldArray({
  control,
  name: 'users'
});

Renderowanie powtarzalnych pól:

{fields.map((field, index) => (
  <div key={field.id} className="user-row">
    <input
      {...register(`users.${index}.name` as const, {
        required: 'Imię jest wymagane'
      })}
      placeholder="Imię"
    />
    <input
      {...register(`users.${index}.email` as const, {
        required: 'Email jest wymagany'
      })}
      placeholder="Email"
    />
    <button type="button" onClick={() => remove(index)}>
      Usuń
    </button>
  </div>
))}

<button
  type="button"
  onClick={() => append({ name: '', email: '' })}
>
  Dodaj użytkownika
</button>

Klucz field.id jest krytyczny – próby używania indeksu jako key w dynamicznych listach kończą się błędami, w których dane jednego wiersza „przeskakują” do innego po usunięciu elementu ze środka.

Walidacja tablicy pól: limity, pusty wiersz na końcu

Prosty warunek typu „przynajmniej jeden element” można spokojnie załatwić schematem walidacji:

const schema = z.object({
  users: z
    .array(
      z.object({
        name: z.string().min(1, 'Imię jest wymagane'),
        email: z.string().email('Nieprawidłowy email')
      })
    )
    .min(1, 'Dodaj przynajmniej jednego użytkownika')
});

W praktyce pojawia się jeszcze problem „pustego ostatniego wiersza”, który użytkownik rozpoczął, ale nie dokończył. Są dwa podejścia:

  • czyścić puste wiersze przed wysłaniem (np. w onSubmit, filtrując po polach wymaganych),
  • traktować każdy rozpoczęty wiersz jako obowiązkowy (czyli komunikaty walidacji na pustych polach).

Drugie rozwiązanie jest prostsze do utrzymania, ale bywa agresywne z perspektywy UX. Przy formularzach, gdzie użytkownicy często porzucają rozpoczęte wpisy (np. listy produktów), filtr wierszy w onSubmit jest rozsądniejszym kompromisem.

Dynamiczne pola zależne od innych wartości

Warunkowe pola – np. „NIP” pokazywany tylko dla firm – kuszą prostym {watch('type') === 'company' && <PoleNIP />}. Technicznie to działa, ale przy bardziej złożonych zależnościach łatwo tracą się z oczu konsekwencje dla walidacji i danych wysyłanych na backend.

const { register, watch } = useForm({
  defaultValues: {
    accountType: 'private',
    companyName: '',
    taxId: ''
  }
});

const accountType = watch('accountType');
<select {...register('accountType')}>
  <option value="private">Konto prywatne</option>
  <option value="company">Konto firmowe</option>
</select>

{accountType === 'company' && (
  <div>
    <input
      {...register('companyName', { required: 'Nazwa firmy jest wymagana' })}
      placeholder="Nazwa firmy"
    />
    <input
      {...register('taxId', { required: 'NIP jest wymagany' })}
      placeholder="NIP"
    />
  </div>
)}

Jeśli walidacja jest zrobiona w schemacie (Yup/Zod), warunkowe reguły zwykle wyglądają czytelniej niż rozproszone required: accountType === 'company' po komponentach, choć wymaga to dodatkowego wysiłku przy utrzymaniu typów.

watch, useWatch i pułapka „prawie jak globalny state”

Hook watch kusi do traktowania stanu formularza jako pół-oficjalnego store’a. Z technicznego punktu widzenia nic nie stoi na przeszkodzie, by na jego podstawie renderować całe poddrzewo formularza, ale każde takie użycie jest potencjalnym źródłem zbędnych renderów.

const { control } = useForm();

const isCompany = useWatch({
  control,
  name: 'accountType',
  defaultValue: 'private'
});

useWatch rozwiązuje typowy problem: używanie globalnego watch() w komponencie potomnym powoduje, że ten komponent renderuje się przy każdej zmianie czegokolwiek. Z useWatch re-render zachodzi tylko, gdy zmieni się konkretne obserwowane pole. To różnica, która przy większych formularzach ma realny wpływ na responsywność.

Resetowanie i przywracanie danych: reset, resetField, keepDirty

W realnych projektach formularz często musi obsłużyć scenariusze typu „cofnij zmiany do stanu z serwera” albo „wyczyść tylko jedno pole”. RHF daje do tego osobne narzędzia:

  • reset(values) – podmiana całego formularza na nowy zestaw wartości, z opcją kontrolowania, czy pola mają zostać oznaczone jako czyste/brudne,
  • resetField(name) – przywrócenie pojedynczego pola do wartości domyślnej,
  • opcje keepDirty, keepErrors, keepTouched – pozwalają zachować wybrane części stanu.
const form = useForm({
  defaultValues: initialData
});

const onReloadFromServer = async () => {
  const freshData = await api.getUser();
  form.reset(freshData, { keepDirty: false });
};

const onClearEmail = () => {
  form.resetField('email', { defaultValue: '' });
};

Brak świadomego użycia reset często kończy się łatkami typu „ręczne podbicie stanu” (setValue w kilku miejscach). Działa, ale utrudnia utrzymanie spójnej definicji „wartości początkowych”, której potem potrzebują np. przyciski „Przywróć”.

Dynamiczne schematy walidacji: gdy logika biznesowa nie jest statyczna

Formularz rejestracyjny, który w różnych krajach ma inne wymagane pola, albo konfigurator produktu, gdzie walidacja zależy od wyboru wariantu – to przykłady, gdzie statyczny schemat walidacji zaczyna być zbyt sztywny.

Schemat można budować dynamicznie, np. na podstawie parametrów lub stanu aplikacji:

function buildSchema(country: string) {
  return z.object({
    firstName: z.string().min(1, 'Imię jest wymagane'),
    lastName: z.string().min(1, 'Nazwisko jest wymagane'),
    taxId:
      country === 'PL'
        ? z.string().min(10, 'NIP musi mieć 10 znaków')
        : z.string().optional()
  });
}

const schema = buildSchema(selectedCountry);

const form = useForm<z.infer<typeof schema>>({
  resolver: zodResolver(schema)
});

Trzeba tylko uważać, by nie budować nowego schematu przy każdym renderze. Gdy schema zmienia się zbyt często, RHF z punktu widzenia formy traktuje to jak podmianę całej logiki walidacji i może zgubić część optymalizacji. Bezpieczniej przeliczać schemat tylko przy realnej zmianie warunków (np. w useMemo zależnym od kraju).

Najczęściej zadawane pytania (FAQ)

Dlaczego formularze w React tak często sprawiają problemy przy rozbudowie?

Na prostym formularzu logowania dwa pola i jeden przycisk zwykle „jakoś działają”. Problemy wychodzą dopiero wtedy, gdy pojawiają się dziesiątki pól, sekcje warunkowe, wielokrokowe procesy i zależności między danymi. Stan formularza zaczyna być rozlany po wielu hookach, komponentach i efektach, a każdy kolejny hotfix dorzuca kolejną warstwę niejawnej logiki.

Źródłem kłopotów nie jest sam React, ale złożoność: jednoczesne ogarnięcie stanu, walidacji (lokalnej i globalnej), UX błędów i komunikacji z backendem. Jeśli każde z tych miejsc rozwija się osobno, bez spójnej strategii, kończy się to trudnym do prześledzenia „rejestrem wyjątków” zamiast przewidywalnego mechanizmu.

Czym React Hook Form różni się od Formika i ręcznego używania useState?

React Hook Form opiera się przede wszystkim na niekontrolowanych polach i natywnym API formularzy. Nie trzyma każdej zmiany w stanie Reacta, dzięki czemu ogranicza liczbę rerenderów. Biblioteki typu Formik i podejście „jeden wielki useState + onChange dla wszystkiego” zazwyczaj bazują na kontrolowanych komponentach, więc każde wciśnięcie klawisza aktualizuje stan i może przeciążać drzewo komponentów.

Drugą różnicą jest centralizacja i struktura. RHF daje jeden hook useForm, spójny obiekt błędów (formState.errors), wbudowane śledzenie touched/dirty i porządne wsparcie dla dynamicznych pól (useFieldArray). Ręczne podejście oznacza zwykle kopiowanie tych samych schematów: stan dla wartości, osobny stan dla błędów, dodatkowe flagi isSubmitting, isValid itp. W mniejszych formularzach to jeszcze działa, w dużych zaczyna być pułapką utrzymaniową.

Kiedy naprawdę opłaca się użyć React Hook Form, a kiedy wystarczy prosty useState?

RHF ma sens przy formularzach złożonych biznesowo: wielokrokowa rejestracja, koszyk zamówienia, rozbudowane panele administracyjne, dynamiczne listy pozycji czy zależne od siebie sekcje. Tam kluczowe staje się: spójna walidacja, porządna obsługa błędów, ograniczenie rerenderów i możliwość sensownego refaktoru w zespole.

Jeśli formularz jest prosty, np. jedno pole wyszukiwania, kilka filtrów czy pojedynczy input do zmiany nazwy, React Hook Form bywa po prostu nadmiarem narzędzi. W takich przypadkach zwykły useState i kawałek lokalnej walidacji jest tańszy w implementacji i wystarczający, o ile nie planujesz późniejszego rozbudowywania tego UI w bardziej złożony proces.

Jak React Hook Form poprawia wydajność dużych formularzy?

Podstawowy mechanizm to oparcie się o niekontrolowane pola i własny, wewnętrzny store zamiast trzymania wszystkich wartości w stanie komponentu. React nie jest informowany o każdej pojedynczej zmianie w inputach, więc nie renderuje całego drzewa przy każdym znaku. Zmiany w formState (np. błędy, isDirty) są propagowane selektywnie.

W praktyce różnica jest widoczna przy długich formularzach lub dynamicznych listach pól. Zamiast „lagującego” UI, który reaguje z opóźnieniem na wpisywanie tekstu, masz odświeżane głównie te elementy, które faktycznie muszą się zmienić: konkretne komunikaty błędów, przycisk submit, podsumowanie. To nie rozwiązuje wszystkich problemów wydajnościowych, ale usuwa jedną z typowych przyczyn: nadmierne rerendery.

Czy React Hook Form rozwiązuje problem niespójnej walidacji między frontendem i backendem?

Nie. RHF jedynie porządkuje walidację po stronie frontendu i pozwala podpiąć bibliotekę walidacyjną (Yup, Zod itd.) przez resolver. Daje to jedną, czytelną ścieżkę, która decyduje, czy formularz przejdzie po stronie klienta, ale nie wymusza żadnej synchronizacji z backendem.

Spójność trzeba zaprojektować osobno: np. dzielić schematy walidacji między front i back, generować je z jednego źródła albo traktować walidację frontendową wyłącznie jako „wczesne ostrzeganie”, a ostateczną prawdę zostawić serwerowi. Bez takiej decyzji można bardzo łatwo skończyć z dwoma różnymi zestawami reguł, nawet jeśli frontend jest zbudowany poprawnie na React Hook Form.

Jak sensownie zarządzać błędami i UX formularza z użyciem React Hook Form?

Samo włączenie RHF nie sprawi, że błędy nagle „będą dobrze wyglądać”. Trzeba określić, kiedy pokazywać komunikaty (po blur, po submit, czy hybrydowo), jak łączyć błędy backendu z błędami lokalnymi i co zrobić z polami ukrytymi warunkowo (czy czyścić ich wartości, czy tylko ukrywać w UI). To są decyzje projektowe, a nie konfiguracja biblioteki.

Przykładowe podejście, które zwykle się sprawdza: wyświetlanie podstawowych błędów po blurze, pełna walidacja po submit, czytelne podsumowanie błędów globalnych (np. na górze formularza) oraz jawne mapowanie błędów serwerowych na konkretne pola po nieudanym requestcie. React Hook Form pomaga, bo błędy trzyma w jednym miejscu (formState.errors), ale to projektant formularza decyduje, jak przełożyć je na sensowny UX.

Najważniejsze wnioski

  • Problemy z formularzami w React wynikają głównie z nawarstwienia się stanu, walidacji i UX obsługi błędów – rzadko z pojedynczej „złej” biblioteki.
  • Proste formularze z tutoriali nie są dobrym punktem odniesienia dla realnych, wieloetapowych i zależnych od siebie pól biznesowych; tam „naiwne” podejścia szybko się sypią.
  • Jedno wielkie useState, ręczne onChange i własne flagi typu touched/dirty/isValid prowadzą do kruchego, trudnego do refaktoryzacji kodu i rozlanej logiki walidacji.
  • Brak jednego źródła prawdy dla walidacji (rozsypanie jej między komponent, helpery i backend) sprawia, że trudno odpowiedzialnie wprowadzać zmiany i unikać sprzecznych reguł.
  • React Hook Form porządkuje formularz przez centralny useForm, niekontrolowane pola, spójną strukturę błędów i wsparcie dla dynamicznych pól, ale nie zastępuje przemyślanej architektury.
  • Niekontrolowane pola i minimalizacja rerenderów w RHF poprawiają wydajność i stabilność, szczególnie przy rozbudowanych formularzach czy polach powtarzalnych (listy pozycji, adresy).
  • Kluczowe korzyści z RHF pojawiają się dopiero wtedy, gdy łączy się go z sensownie zaprojektowanym UX błędów, spójną walidacją frontend–backend i jasnym podziałem odpowiedzialności między komponentami.

Źródła

  • React Hook Form – API Reference. React Hook Form – Oficjalna dokumentacja hooków useForm, register, handleSubmit, formState
  • React Hook Form – Advanced Usage. React Hook Form – Zaawansowane wzorce: dynamiczne pola, useFieldArray, integracje z walidacją
  • React – Forms. Meta – Oficjalne wyjaśnienie kontrolowanych i niekontrolowanych komponentów formularzy
  • Yup – Schema Validation. Yup – Dokumentacja walidacji schematów, integracja z formularzami w React
  • Zod – TypeScript-first schema validation. Zod – Walidacja schematów i typów, użycie jako resolver w formularzach