# React Hook Form i Zod: rok w produkcji bez zbędnego kodu i ukrytych pułapek
Po roku intensywnego używania duetu React Hook Form i Zod w produkcji na dużym projekcie zespół potwierdza: deklaratywne podejście do formularzy redukuje objętość kodu o 30–40%, ale wymaga dbałości o detale implementacji. Przedstawiamy pułapki, na które natknęliśmy się, i sposoby ich obejścia.
Od MobX do deklaratywnych formularzy: dlaczego zmieniać podejście?
W poprzednich iteracjach projektu formularze budowano w stylu MVC na bazie MobX. Ta metoda pozwalała kontrolować stan, ale generowała zbędny kod: dla każdego pola potrzebne były oddzielne metody w kontrolerze, tablice błędów i ręczna synchronizacja z UI. Na przykład, dodanie nowego pola wymagało stworzenia:
export class SomeController {
public setDuration = (v: number) => {
this.store.duration = v;
}
}
Taki szablonowy kod nie należał do logiki biznesowej, ale pochłaniał nawet 60% objętości komponentu. Przejście na React Hook Form (RHF) z Zod rozwiązało problem: schemat walidacji opisuje się deklaratywnie, a biblioteka automatyzuje synchronizację stanu i obsługę błędów.
Przykład podstawowego formularza:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Enter name'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(0, 'Invalid vozrast').optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function ExampleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit">Send</button>
</form>
);
}
Efekt: objętość kodu spadła, utrzymanie stało się prostsze, a tempo tworzenia nowych formularzy wzrosło. Jednak w trakcie eksploatacji wyszły na jaw niuanse, których nie opisano w dokumentacji.
Trzy pułapki w duecie React Hook Form i Zod
Przez rok używania RHF i Zod napotkaliśmy trzy kluczowe problemy, które mogą zakłócić działanie formularza w określonych scenariuszach.
1. Obsługa undefined i null
RHF nie obsługuje undefined jako wartości pola. Przy próbie ustawienia:
methods.setValue('age', undefined)
pole ignoruje zmianę. Dokumentacja o tym wspomina, ale nie jest to oczywiste. Standardowe rozwiązanie to użycie null i zadeklarowanie pola jako .nullable():
const schema = z.object({
age: z.number().nullable()
});
methods.setValue('age', null);
Jednak to może kolidować z niestandardowymi komponentami oczekującymi liczb. Na przykład komponent wprowadzania wieku może się wysypać na null. Odwrotna sytuacja: komponent zwraca undefined przy czyszczeniu, ale RHF nie przetwarza tej wartości.
Dla przycisku resetowania formularza pojawia się dodatkowa komplikacja. Metoda reset() ustawia wartości z defaultValues, ale jeśli są zadeklarowane jako undefined, reset nie zadziała. Rozwiązanie — użyć null lub pustego ciągu dla pól tekstowych.
2. Nieoczekiwane błędy typów w Zod
Problem arises przy niezgodności typów danych. Na przykład schemat:
const schema = z.object({
field1: z.string().min(1, 'Fill field').optional()
});
Jeśli komponent zwróci null (zamiast undefined), Zod zgłosi błąd invalid type (expected string received null). Użytkownik zobaczy komunikat systemowy zamiast twojego tekstu.
Rozwiązanie — jawnie podać tekst błędu dla wszystkich typów walidacji:
const schema = z.object({
field1: z.string('Fill field').min(1, 'Fill field').optional()
});
To gwarantuje, że każdy błąd wyświetli twój komunikat.
3. Niestabilność flagi isDirty
Flaga formState.isDirty wskazuje, czy w formularzu zaszły zmiany. Jednak przy inicjalizacji z pustym defaultValues:
const methods = useForm({
resolver: zodResolver(formSchema),
defaultValues: {},
});
isDirty staje się true zaraz po wyrenderowaniu, choć użytkownik jeszcze nic nie zmienił. Przy tym dirtyFields pozostaje pustym obiektem.
To wynika z niezgodności struktury defaultValues i schematu. Aby uniknąć fałszywych alarmów, upewnij się, że defaultValues zawiera wszystkie pola zdefiniowane w schemacie, nawet z undefined lub null.
Jak unikać typowych błędów?
Na podstawie doświadczenia polecamy sprawdzone praktyki:
- Do obsługi pustych wartości:
- Pola tekstowe: używaj pustego ciągu jako domyślnego.
- Pola liczbowe/obiektowe: stosuj .nullable() w Zod i null w wartościach.
- Zawsze sprawdzaj, czy niestandardowe komponenty obsługują wybrany typ.
- Do niezawodnej walidacji:
- Podawaj ogólny tekst błędu dla wszystkich etapów walidacji, jak w powyższym przykładzie.
- Testuj scenariusze z null i undefined na etapie tworzenia komponentów.
- Do poprawnego działania isDirty:
- Inicjalizuj defaultValues zgodnie ze schematem, w tym wszystkie pola.
- Unikaj pustego obiektu {} — jawnie wymieniaj pola z undefined lub null.
Dodatkowo zalecamy dodać do projektu wspólne helpery do pracy z formularzami, np. funkcję resetu uwzględniającą niuanse RHF:
const resetForm = () => {
methods.reset({
name: null,
email: null,
age: null,
});
};
Co ważne
- Redukcja kodu nie zwalnia z testowania: Deklaratywne podejście oszczędza czas, ale wymaga dokładnego sprawdzenia przypadków brzegowych.
- Typizacja to twój sojusznik: Używanie Zod z TypeScript zapobiega wielu błędom, ale wymaga poprawnego skonfigurowania schematów.
- Dokumentuj niuanse: Zapisz w wewnętrznej dokumentacji projektu obejścia dla typowych problemów z RHF i Zod.
— Editorial Team
Brak komentarzy.