StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

RealizacjeO mnieBlog
Porozmawiajmy
PL
EN

Nowoczesne strony internetowe dla firm, które myślą odważnie.

Przewiń do góry

Nazwa

StriveLab Maciej Sala

NIP

6772218995

REGON

524008527

E-mail

contact@strivelab.pl

Usługi główne
  • Tworzenie stron internetowych
  • Strony internetowe Next.js
  • Strony internetowe Astro
  • Strony internetowe React
Inne usługi
  • Usługi
  • SEO & Performance Sprint
  • QA & Stabilizacja
  • Konsultacje Product / Delivery
  • Aplikacje webowe Next.js
  • Współpraca ciągła
Strony
  • O mnie
  • Usługi
  • Realizacje
  • Blog

© 2026 StriveLab.pl

Polityka prywatności
ReactFormularzeZod

React Hook Form + Zod — kompletny stack formularzowy, który skaluje się do dowolnego projektu

Kompletny stack formularzowy React — React Hook Form + Zod. Walidacja, dynamiczne pola, multi-step, integracja z shadcn/Radix, porównanie z React 19 Actions. Z przykładami produkcyjnymi.

OpublikujLinkedInFacebookWyślij
Autor
Maciej Sala
Opublikowano
13 maja 2026 08:16
Czytanie
5 min czytania
Aktualizacja
Wersja pierwotna

W skrócie

  • React Hook Form ogranicza re-rendery formularza, bo pola działają głównie jako Uncontrolled inputs przechowują aktualną wartość w DOM, a nie w stanie Reacta aktualizowanym przy każdym znaku..
  • Zod daje jeden schemat walidacji, z którego możesz wyprowadzić typy TypeScript i logikę serwerową.
  • Controller w React Hook Form służy do integracji kontrolowanych komponentów UI, które nie działają prosto przez register. zostaw dla kontrolowanych komponentów UI, a zwykłe inputy obsługuj przez register.
  • React 19 Actions są prostsze dla małych formularzy, ale RHF + Zod lepiej skaluje się do złożonych flow.

Formularze w React to pole minowe. Między useState na każde pole, ręczne preventDefault, walidacją po obu stronach, obsługą błędów, reset, integracją z UI kitami — prosty formularz kontaktowy potrafi urosnąć do 200 linii kodu. Gdy dochodzą dynamiczne pola (dodawanie/usuwanie), multi-step wizard, asynchroniczna walidacja — sytuacja robi się krytyczna.

React Hook Form zarządza formularzami w React, a Zod definiuje schemat walidacji i typy danych. to stack, który w 2026 roku traktuje formularze jak powinno się je traktować — deklaratywnie, typowo, wydajnie. W tym artykule pokazuję, jak realnie budować formularze w tym stacku od prostych po najbardziej skomplikowane, i porównuję z React 19 Actions — żebyś wiedział, kiedy który wybrać.

Dlaczego nie useState per pole

Klasyczne podejście z lat 2017–2020:

Code
function ContactForm() {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const validate = () => {
    const newErrors = {};
    if (!email) newErrors.email = 'Email required';
    if (!email.includes('@')) newErrors.email = 'Invalid email';
    if (!name) newErrors.name = 'Name required';
    if (!message || message.length < 10) newErrors.message = 'Message too short';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
 
  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!validate()) return;
    setIsSubmitting(true);
    // ... fetch call
    setIsSubmitting(false);
  };
 
  return (/* JSX z useState'ami na każdym polu */);
}

Działa, ale problemów jest cała lista:

  • Każda zmiana re-renderuje cały formularz. Przy 10 polach to 10 re-renderów dla każdego wpisanego znaku.
  • Walidacja ręczna — łatwo coś pominąć, trudno utrzymać spójność po stronie serwera.
  • Brak reuse — każdy formularz od nowa.
  • Brak wsparcia dla dynamicznych pól (dodawanie/usuwanie itemów).

React Hook Form rozwiązuje wszystko powyższe, a Zod dodaje type-safe walidację. Razem — default stack dla każdego nowego formularza.

Instalacja i minimalny setup

Code
npm install react-hook-form zod @hookform/resolvers

Trzy zależności:

  • react-hook-form — core biblioteka formularzy.
  • zod — biblioteka walidacji / schema.
  • @hookform/resolvers — adapter łączący RHF z Zod (albo Yup, Joi, itp.).

Minimalny formularz:

Code
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
 
const ContactSchema = z.object({
  email: z.string().email('Nieprawidłowy email'),
  name: z.string().min(2, 'Imię jest za krótkie'),
  message: z.string().min(10, 'Wiadomość musi mieć co najmniej 10 znaków'),
});
 
type ContactForm = z.infer<typeof ContactSchema>;
 
function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ContactForm>({
    resolver: zodResolver(ContactSchema),
  });
 
  const onSubmit = async (data: ContactForm) => {
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} type="email" />
      {errors.email && <p>{errors.email.message}</p>}
 
      <input {...register('name')} />
      {errors.name && <p>{errors.name.message}</p>}
 
      <textarea {...register('message')} />
      {errors.message && <p>{errors.message.message}</p>}
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Wysyłanie...' : 'Wyślij'}
      </button>
    </form>
  );
}

Zalety:

  • register — spinka input ze stanem formularza, bez onChange handlerów.
  • Walidacja automatyczna — Zod schema → błędy.
  • Typy wywodzone z Zod schema — z.infer<typeof ContactSchema> daje TypeScript typ.
  • Minimum re-renderów — RHF pod spodem używa uncontrolled inputs z ref'ami.
  • isSubmitting — pending state out of the box.

Dla porównania — ten sam formularz w klasycznym podejściu to 60-80 linii.

register vs Controller

RHF ma dwa API do łączenia inputów ze stanem formularza.

register — działa z natywnymi <input>, <textarea>, <select>. Najlepsze performance, najmniej kodu.

Code
<input {...register('email')} />

Controller — wrapper dla custom components, które nie mają ref-a albo używają nietypowego API (np. date pickery, rich text editory, react-select).

Code
import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';
 
<Controller
  name="date"
  control={control}
  render={({ field }) => (
    <DatePicker
      selected={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
    />
  )}
/>

Regulation: wybieraj register kiedy tylko możesz, Controller tylko gdy musisz.

Walidacja — od prostych po zaawansowane

Zod daje świetne API do walidacji. Od prostej po wzajemne zależności pól:

Code
const SignupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(64),
  confirmPassword: z.string(),
  birthdate: z.coerce.date().max(
    new Date(Date.now() - 18 * 365 * 24 * 60 * 60 * 1000),
    'Musisz mieć co najmniej 18 lat'
  ),
  terms: z.literal(true, {
    errorMap: () => ({ message: 'Musisz zaakceptować regulamin' }),
  }),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Hasła się nie zgadzają',
    path: ['confirmPassword'],
  }
);

refine pozwala na walidację międzypolową (hasła muszą się zgadzać, data końcowa musi być po starcie). Błąd jest umieszczany na path (w tym wypadku confirmPassword), więc user widzi go przy odpowiednim polu.

Asynchroniczna walidacja

Przykład: sprawdzenie, czy email nie jest zajęty. Zod nie obsługuje async natywnie, ale RHF ma dla tego osobne API:

Code
<input
  {...register('email', {
    validate: {
      unique: async (email) => {
        const response = await fetch(`/api/check-email?email=${email}`);
        const { available } = await response.json();
        return available || 'Email jest już zajęty';
      },
    },
  })}
  type="email"
/>

Validate odpala się po blur pola (konfigurowalne przez mode w useForm). Dla user experience zwykle łączę to z debounce, żeby nie robić requestu po każdej literze.

Dynamic fields — useFieldArray

Formularz, gdzie user może dodawać / usuwać items (np. lista gości na wydarzenie, pozycje faktury, custom fields).

Code
import { useFieldArray, useForm } from 'react-hook-form';
 
const InvoiceSchema = z.object({
  customerName: z.string().min(1),
  items: z.array(z.object({
    description: z.string().min(1),
    quantity: z.number().min(1),
    price: z.number().min(0),
  })).min(1, 'Dodaj co najmniej jedną pozycję'),
});
 
type InvoiceForm = z.infer<typeof InvoiceSchema>;
 
function InvoiceForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<InvoiceForm>({
    resolver: zodResolver(InvoiceSchema),
    defaultValues: { customerName: '', items: [{ description: '', quantity: 1, price: 0 }] },
  });
 
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('customerName')} placeholder="Klient" />
 
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.description`)} placeholder="Opis" />
          <input {...register(`items.${index}.quantity`, { valueAsNumber: true })} type="number" />
          <input {...register(`items.${index}.price`, { valueAsNumber: true })} type="number" step="0.01" />
          <button type="button" onClick={() => remove(index)}>Usuń</button>
        </div>
      ))}
 
      <button type="button" onClick={() => append({ description: '', quantity: 1, price: 0 })}>
        Dodaj pozycję
      </button>
 
      {errors.items && <p>{errors.items.message}</p>}
      <button type="submit">Wystaw fakturę</button>
    </form>
  );
}

useFieldArray zarządza dodawaniem, usuwaniem, reordering. field.id to unikalny klucz (RHF generuje go wewnętrznie) — nie używaj index jako klucz, bo remove/reorder łamie state.

Multi-step forms

Wizard z krokami — najłatwiej przez FormProvider + osobne komponenty per krok.

Code
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { useState } from 'react';
 
const WizardSchema = z.object({
  account: z.object({
    email: z.string().email(),
    password: z.string().min(8),
  }),
  profile: z.object({
    firstName: z.string().min(2),
    lastName: z.string().min(2),
    birthdate: z.coerce.date(),
  }),
  preferences: z.object({
    newsletter: z.boolean(),
    theme: z.enum(['light', 'dark']),
  }),
});
 
type WizardForm = z.infer<typeof WizardSchema>;
 
function SignupWizard() {
  const [step, setStep] = useState(0);
  const methods = useForm<WizardForm>({
    resolver: zodResolver(WizardSchema),
    defaultValues: { account: {}, profile: {}, preferences: {} },
  });
 
  const steps = ['account', 'profile', 'preferences'] as const;
 
  const handleNext = async () => {
    const isValid = await methods.trigger(steps[step]);
    if (isValid) setStep(step + 1);
  };
 
  const onSubmit = (data: WizardForm) => {
    // wysłać do API
  };
 
  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {step === 0 && <AccountStep />}
        {step === 1 && <ProfileStep />}
        {step === 2 && <PreferencesStep />}
 
        <div>
          {step > 0 && <button type="button" onClick={() => setStep(step - 1)}>Wstecz</button>}
          {step < steps.length - 1 && <button type="button" onClick={handleNext}>Dalej</button>}
          {step === steps.length - 1 && <button type="submit">Zakończ</button>}
        </div>
      </form>
    </FormProvider>
  );
}
 
function AccountStep() {
  const { register, formState: { errors } } = useFormContext<WizardForm>();
 
  return (
    <div>
      <input {...register('account.email')} type="email" />
      {errors.account?.email && <p>{errors.account.email.message}</p>}
 
      <input {...register('account.password')} type="password" />
      {errors.account?.password && <p>{errors.account.password.message}</p>}
    </div>
  );
}

trigger waliduje pojedyncze pola lub grupy — używam go do przejścia do następnego kroku (nie submit, tylko check czy aktualny krok jest valid).

Integracja z shadcn/ui i Radix

Jeśli używasz shadcn/ui, integracja z RHF jest wbudowana:

Code
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
 
function ContactForm() {
  const form = useForm<ContactForm>({
    resolver: zodResolver(ContactSchema),
  });
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input {...field} type="email" />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Wyślij</Button>
      </form>
    </Form>
  );
}

Więcej kodu niż bezpośredni register, ale dostajesz konsystentny design system, automatyczne pokazywanie błędów, accessibility (labels, aria-describedby) out of the box.

Server-side walidacja — ten sam schema

Genialność Zod: używasz tego samego schema na kliencie i serwerze.

Code
// lib/schemas.ts
import { z } from 'zod';
 
export const ContactSchema = z.object({
  email: z.string().email(),
  message: z.string().min(10),
});
 
// app/api/contact/route.ts
import { ContactSchema } from '@/lib/schemas';
 
export async function POST(request: Request) {
  const body = await request.json();
  const parsed = ContactSchema.safeParse(body);
 
  if (!parsed.success) {
    return Response.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 400 }
    );
  }
 
  // parsed.data jest zwalidowany i typowany
  await saveToDatabase(parsed.data);
  return Response.json({ success: true });
}
 
// components/ContactForm.tsx
import { ContactSchema } from '@/lib/schemas';
import { zodResolver } from '@hookform/resolvers/zod';
 
const form = useForm<z.infer<typeof ContactSchema>>({
  resolver: zodResolver(ContactSchema),
});

Jedna walidacja, dwa miejsca. Bez duplikowania logiki. Jeśli dodasz pole, wystarczy zmienić schema — klient i serwer oba dostają update.

Zod 4 — na co uważać

Zod 4 jest stabilny i warto korzystać z jego modelu input / output, szczególnie gdy schema robi transformacje. To ważne w formularzach, bo typ danych wpisanych przez usera nie zawsze jest tym samym typem, który chcesz zapisać w bazie.

Code
const ProductSchema = z.object({
  name: z.string().min(2),
  price: z.coerce.number().positive(),
  availableFrom: z.string().transform((value) => new Date(value)),
});
 
type ProductFormInput = z.input<typeof ProductSchema>;
type ProductPayload = z.output<typeof ProductSchema>;

ProductFormInput opisuje dane z formularza, a ProductPayload dane po walidacji i transformacji. Dzięki temu nie musisz oszukiwać TypeScriptu, gdy HTML input zwraca string, a backend oczekuje liczby albo daty.

Top tip

Jeśli schema ma asynchroniczne refine albo transformacje, użyj safeParseAsync. Zwykłe safeParse nie obsłuży walidacji, która czeka na API lub bazę danych.

React Hook Form vs React 19 Actions

W 2026 roku mamy dwa podejścia do formularzy. Kiedy które?

React 19 Actions (opisane w osobnym artykule) — prostsze, działają bez klientowego JS, dobre dla prostych formularzy.

React Hook Form + Zod — więcej features, lepsze DX dla skomplikowanych formularzy, klientowa walidacja real-time.

Decision tree:

CaseUżyj
Kontakt, newsletter, prosty submitReact 19 Actions
Progressive enhancement (JS optional)React 19 Actions
Login/signup z szybką walidacjąRHF + Zod
Multi-step wizardRHF + Zod
Dynamic fields (array/nested)RHF + Zod
Real-time walidacja (np. email availability)RHF + Zod
Integracja z custom UI kitRHF + Zod

Oba mogą współistnieć w jednym projekcie — Actions dla prostych submitów, RHF dla rozbudowanych formularzy.

Typowe pułapki

1. valueAsNumber dla liczb. Bez tego dostajesz string:

Code
<input {...register('age', { valueAsNumber: true })} type="number" />

2. defaultValues vs values. defaultValues ustawia state raz (przy mount). values reaktywnie aktualizuje formularz gdy zmienia się external data — używaj, gdy ładujesz dane z API:

Code
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const form = useForm({ values: user });  // automatyczne update gdy user się zmieni

3. Validation modes. Domyślnie RHF waliduje po submit. Dla real-time feedback ustaw mode: 'onChange' lub mode: 'onBlur':

Code
const form = useForm({
  mode: 'onBlur',  // waliduje przy blur każdego pola
});

4. Reset po submit. Po udanym submit formularz nie resetuje się automatycznie. Dodaj ręcznie:

Code
const onSubmit = async (data) => {
  await saveData(data);
  form.reset();  // albo form.reset(defaultValues)
};

Podsumowanie

React Hook Form + Zod to stack, który pokrywa 90% potrzeb formularzowych. Dla prostych przypadków React 19 Actions mogą wystarczyć, ale gdy tylko dochodzi walidacja real-time, dynamic fields, multi-step wizard albo integracja z custom UI — RHF jest jedynym sensownym wyborem. Zod dokłada type-safe walidację po obu stronach z jednym schema.

Jeśli masz projekt z rozwalonymi formularzami i chcesz je posprzątać — napisz do mnie. W StriveLab React migracje formularzy z useState na RHF + Zod są jedną z najczęstszych usług i zwykle tniemy 50-70% kodu przy jednoczesnej poprawie UX.

Często zadawane pytania

Pracuję z tym zawodowo.

Jeśli chcesz uporządkować frontend, architekturę React i Next.js, poprawić jakość wdrożenia albo przyspieszyć development bez psucia maintainability, skontaktuj się ze mną. Na co dzień pracuję hands-on przy projektach, w których kod, UX i decyzje produktowe muszą działać razem.

Skontaktuj się ze mną
Maciej Sala

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Moje artykułyWięcej o mnie

Seria

React w praktyce 2026
  1. 1React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce
  2. 2React Compiler w 2026 — czy useMemo i useCallback są już martwe?
  3. 3React Query (TanStack) vs SWR vs useEffect — kompletny przewodnik po fetchingu w 2026
  • Dlaczego nie useState per pole1 min
  • Instalacja i minimalny setup1 min
  • register vs Controller1 min
  • Walidacja — od prostych po zaawansowane1 min
  • Dynamic fields — useFieldArray1 min
  • Multi-step forms1 min
  • Integracja z shadcn/ui i Radix1 min
  • Server-side walidacja — ten sam schema1 min
  • Zod 4 — na co uważać1 min
  • React Hook Form vs React 19 Actions1 min
  • Typowe pułapki1 min
  • Podsumowanie1 min

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
SEO w Astro — Core Web Vitals, dane uporządkowane i techniczny fundament rankingu w 2026
SEO w Astro — Core Web Vitals, dane uporządkowane i techniczny fundament rankingu w 2026

Jak zbudować stronę w Astro, która dominuje w SEO — Core Web Vitals, sitemap, robots.txt, metadane, dane uporządkowane i GEO/AEO. Przewodnik techniczny z konkretnymi implementacjami.

Maciej Sala

Maciej Sala

Founder Strivelab

13 maja 2026
React Query (TanStack) vs SWR vs useEffect — kompletny przewodnik po fetchingu w 2026
React Query (TanStack) vs SWR vs useEffect — kompletny przewodnik po fetchingu w 2026

Jak pobierać dane w React w 2026? Porównanie TanStack Query, SWR i useEffect. Kiedy Server Components wystarczą, kiedy potrzebujesz cache, invalidation, optimistic updates i infinite queries.

Maciej Sala

Maciej Sala

Founder Strivelab

13 maja 2026
React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce
React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce

React 19 Actions zmieniają sposób pisania formularzy. Przewodnik po useActionState, useOptimistic, useFormStatus i akcjach na atrybucie form action. Z przykładami i migracją z onSubmit.

Maciej Sala

Maciej Sala

Founder Strivelab

13 maja 2026