Ręcznie budowane formularze w React: kontrolowane pola wejściowe, walidacja i stany wysyłania
Dowiedz się, jak działają formularze w React – od kontrolowanych pól wejściowych i obsługiwaczy różnych typów po walidację, stany wysyłania, pola dynamiczne, przesyłanie plików oraz sytuacje, w których biblioteki formularzy są przydatne.
Źródła: React Forms: A Complete Guide from Basics to Production Patterns
Logowanie, zakupy, wyszukiwanie, ustawienia profilu, ankiety – niemal każde aplikacja opiera się na formularzach. W React formularz oznacza zarządzanie wartościami, walidacją, komunikatami o błędach, wysyłką danych, stanem ładowania i błędami serwera, polami dodawanymi w czasie wykonywania aplikacji, możliwością sformatowania formularza oraz optymalizacją wydajności. Ten przewodnik przedstawia ten proces krok po kroku, od kontrolowanych pól wejściowych po kompletny formularz rejestracyjny bez użycia bibliotek, pokazując również, kiedy warto zastosować React Hook Form i Zod.
Jak formularz funkcjonuje w aplikacji React
Formularz zbiera informacje od użytkownika, jak w tym ekranie rejestracji:
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React łączy te pola wejściowe z logiką aplikacji, a prawie każdy formularz przechodzi przez ten sam cykl życia:
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Komponenty kontrolowane: stan jako źródło prawdy
W komponencie kontrolowanym value pola wejściowego pochodzi ze stanu React, a każda zmiana jest zapisywana z powrotem za pomocą onChange.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
Ustawienie value={email} oznacza, że to React decyduje, co ma być wyświetlone. Każdy nacisk klawisza przechodzi przez ten cykl:
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Ponieważ wyświetlana wartość zawsze pochodzi ze stanu, stan jest jedynym źródłem prawdy.
Nazwa „kontrolowany” odzwierciedla fakt, że pole wejściowe nie posiada własnej wartości:
<input
value={email}
onChange={handleChange}
/>
Dane przepływają ze stanu do elementu, poprzez wpisywanie tekstu przez użytkownika i obsługę zdarzeń, a następnie z powrotem do stanu:
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
Jeden obiekt stanu dla wielu pól
Korzystanie z osobnego wywołania useState dla każdego pola powoduje słabą skalowalność:
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Jedno obiekt z kluczem opartym na nazwie pola jest łatwiejszy w utrzymaniu. Ten format przechowuje trzy pola w jednym obiekcie, dzieli się jedną funkcją handleChange i zapisuje dane po wysłaniu formularza.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
Kluczową linią jest obliczana właściwość [name]: value. Atrybut name każdego pola wprowadzania danych odpowiada kluczu stanu, więc jedna funkcja aktualizuje to pole, które zostało aktywowane.
Dla pól tekstowych funkcja ta odczytuje treść elementu z zdarzenia:
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target to element wprowadzania danych, więc event.target.value to jego aktualna treść tekstowa:
User types: Salim
event.target.value
↓
"Salim"
Prawidłowe obsługiwanie każdego typu pola
Formularze zawierają pola tekstowe, hasła, liczbowe, checkboxy, radio, select, textarea, pliki, daty oraz kontrolery zakresu. Większość z nich follows ten sam schemat, z kilkoma istotnymi różnicami.
Tekst, hasło i liczby
Pole tekstowe łączy value z jego kluczem:
<input
type="text"
value={form.name}
onChange={handleChange}
/>
Pole na hasło różni się tylko parametrem type:
<input
type="password"
value={form.password}
onChange={handleChange}
/>
Tak samo jest z polem numerycznym:
<input
type="number"
value={form.age}
onChange={handleChange}
/>
Problem polega na tym, że event.target.value jest łańcuchem nawet w przypadku ustawienia type="number". Poniższy fragment łączy kod z wyjaśnieniem – chodzi o to, że otrzymujemy wartość "25" zamiast 25, więc konieczna jest funkcja Number() do jej przekształcenia.
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Uwaga na puste wartości: Number("") zwraca 0, dlatego wiele form zachowuje surowy łańcuch i przekształca go dopiero podczas weryfikacji lub wysyłki.
Pole wyboru używa atrybutu checked
Dla pola wyboru należy powiązać atrybut checked z wartością logiczną i odczytywać event.target.checked:
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value w polu typu checkbox zwraca jego atrybut value (domyślnie "on"), a nie informację o tym, czy jest zaznaczone.
Grupy select, textarea i radio
Element select jest kontrolowany za pomocą atrybutu value znajdującego się w tym elemencie; pusta pierwsza opcja służy jako miejsce zastępcze.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
React traktuje textarea jak zwykłe pole wejścia, używając do niego właściwości value zamiast tekstu zawartego wewnątrz.
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Przyciski radio w grupie dzielą się tą samą wartością name. Każdy z nich jest sprawdzany, gdy jego wartość value odpowiada aktualnemu stanowi, a wspólny obsługiwacz zapisuje wybraną opcję do form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Przejmowanie procesu wysyłania
Domyślnie przeglądarka sama wysyła formularz i może przenieść się do innej strony lub ponownie ją załadować. Aplikacje React obsługują wysyłanie danych w JavaScript, więc funkcja obsługująca wysyłkę zaczyna się od event.preventDefault(), tak jak w przykładzie rejestracji. Następnie tok działania zależy od Ciebie:
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Walidacja przed żądaniem
Walidacja sprawdza, czy dane wejściowe są dopuszczalne. Pierwsza wersja gromadzi błędy opisane pod kątem poszczególnych pól:
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
Funkcja obsługująca wysyłkę najpierw waliduje dane, przechowuje błędy i wychodzi wcześniej, jeśli jakiekolwiek istnieją:
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Błędy znajdują się w osobnym stanie:
const [errors, setErrors] = useState({});
W danym momencie może on zawierać:
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Każde pole wyświetla swoją informację warunkowo:
{errors.email && (
<p>{errors.email}</p>
)}
Lepsza funkcja validate przyjmuje formularz jako argument, co sprawia, że jest czysta i poddawalna testom, oraz sprawdza format i długość po pojawieniu się danego pola:
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
Obsługa wywołuje to w ten sam sposób:
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
Test includes("@") łapie tylko oczywiste błędy pisowni; prawdziwym dowodem na poprawność adresu jest e-mail z potwierdzeniem.
Walidacja po stronie klienta odbywa się w przeglądarce i zazwyczaj obejmuje:
- Pole obowiązkowe
- Format adresu e-mail
- Minimalna długość hasła
- Potwierdzenie hasła
Daje szybką informację zwrotną, lepsze doświadczenie użytkownika oraz mniej niepotrzebnych żądań, ale nie stanowi bariery bezpieczeństwa. Serwer musi powtórzyć te sprawdzenia i odpowiedzieć na pytania, których przeglądarka nie może zadać:
- Czy adres e-mail jest już zarejestrowany?
- Czy użytkownik ma uprawnienia?
- Czy cena jest ważna?
- Czy ten użytkownik może wykonać tę operację?
Aplikacje bezpieczne wykorzystują obie formy walidacji. Nigdy nie pisz takiej logiki:
if (clientValidationPassed) {
// Data is safe
}
Weryfikacje przeglądarki można obejść poprzez edycję strony lub bezpośrednie wywołanie API, dlatego serwer weryfikuje każdą prośbę niezależnie:
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Modelowanie stanów wysyłania
Formularz produkcyjny przechodzi przez różne stany, które powinien odzwierciedlać interfejs użytkownika:
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Dwa elementy stanu je śledzą:
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
Obsługa asynchroniczna ustawia flagę, usuwa stare błędy serwera, czeka na prośbę, zapisuje przyjazną wiadomość w przypadku niepowodzenia i w każdym razie resetuje flagę w bloku finally.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Wyłączanie i przemianowywanie nazwy przycisku podczas wysyłania prośby zapobiega dublowanym wysyłkom:
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
Życiowy cykl przycisku odpowiada procesowi wysyłania prośby:
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Wprowadzanie danych kontrolowane versus niekontrolowane
Przy wejściu kontrolowanym stan React przechowuje wartość. Poniższy blok pokazuje zwięzłą wersję, pełny komponent oraz przepływ danych, oddzielone liniami z gwiazdkami.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
Przy wejściu niekontrolowanym DOM przechowuje wartość, a React odczytuje ją za pomocą ref, zwykle przy wysyłaniu formularza, bez przechowywania każdego naciśnięcia klawisza:
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Wybieraj wejścia kontrolowane do natychmiastowej walidacji, warunkowego interfejsu użytkownika, wartości pochodnych, formatowania, stanu sterowanego przez React lub niestandardowych interakcji. Ponieważ React zna wartość, interfejs może reagować natychmiastowo:
{password.length < 8 && (
<p>Password is too short</p>
)}
Wybierz niekontrolowane pola wejściowe dla prostych form, gdy stan po każdym naciśnięciu klawisza nic nie dodaje, gdy chcesz mniej aktualizacji lub gdy biblioteka zarządza referencjami za ciebie. Działania form w React 19 również opierają się na tym modelu; zobacz asynchroniczne formy z useActionState i useOptimistic.
Kiedy biblioteka do form zasługuje na uwagę
React Hook Form
Ręczne podłączanie dziesiątek pól jest uciążliwe. Funkcja register w React Hook Form łączy pole wejściowe i określa jego zasady, handleSubmit weryfikuje dane przed wywołaniem funkcji zwrotnej, a formState.errors pokazuje komunikaty błędów.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
Uproszcza rejestrację, walidację, obsługę błędów, wysyłanie danych, resetowanie formularza, a także obsługę pól dynamicznych i stanu formularza. Ponieważ domyślnie rejestruje pola niekontrolowane, unika ponownego renderowania całego formularza przy każdym naciśnięciu klawisza, w odróżnieniu od prostych formularzy kontrolowanych.
Walidacja schematu za pomocą Zod
Gdy liczba reguł rośnie, rozproszone instrukcje if stają się trudne do zarządzania. Zod opisuje oczekiwany kształt danych w jednej deklaracji:
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
Powszechnym połączeniem w produkcji jest używanie biblioteki do zarządzania stanem oraz schematu do definiowania reguł:
React Hook Form
+
Zod
↓
Form state + validation
Schemat może być tak zwięzły:
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form umożliwia włączenie schematu poprzez mechanizm resolvera, co jest przydatne w przypadku dużych formularzy, których reguły muszą pozostać spójne.
Pola dynamiczne, warunkowe i zależne
Powtarzające się grupy
Użytkownicy często dodają nowe wpisy w razie potrzeby, na przykład informacje o wykształceniu:
Education
Degree [________]
College [________]
[ + Add Education ]
Zachowaj je jako tablicę obiektów:
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
Dodawanie wiersza dodaje pusty obiekt:
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
Wyświetlanie odbywa się poprzez przeglądanie tablicy:
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
Indeks key jest bezpieczny tylko wtedy, gdy wiersze nigdy nie są usuwane ani przestawiane; w przeciwnym razie należy używać stabilnych identyfikatorów, ponieważ tekst o określonym typie może zostać przypisany do niewłaściwego wiersza.
Pola warunkowe
Część pól zależy od wcześniejszego wyboru, na przykład dane firmy w kontach biznesowych:
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
Zarządza tym wyrażenie warunkowe:
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
Jeśli użytkownik wróci, usuń lub wyklucz wartości ukryte, aby nie zostały wysłane przestarzałe dane.
Wybory kaskadowe
Kraj, stan i miasto tworzą łańcuch:
Country
↓
State
↓
City
Zmiana na wyższym poziomie sformatowuje wszystko poniżej przed pobraniem nowych opcji:
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Ponieważ opcje pochodzą z serwera, TanStack Query dobrze współpracuje z biblioteką formularzy w zakresie cacheowania i zarządzania stanami ładowania.
Zapisy plików
Pole wejścia na pliki nie można kontrolować; odczytaj plik z event.target.files:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
Aby go przesłać, wyślij ciało typu FormData:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
Nie ustawiaj samodzielnie Content-Type: multipart/form-data za pomocą fetch i FormData; przeglądarka generuje ten nagłówek wraz z wymaganym rozgraniczeniem.
Zachowanie szybkości dużych formularzy
Duże, złożone formularze obciążają prosty formularz kontrolowany, który przerysowuje się przy każdym naciśnięciu klawisza:
User types
↓
setState
↓
Render
↓
Large form tree
Potrzebne środki zaradcze:
- Zachowuj stan lokalnie, a nie globalnie bez konieczności.
- Rozdziel formularz na sekcje, aby każda zmiana powodowała mniej przerysowań:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Użyj odpowiedniej biblioteki, takiej jak React Hook Form.
- , np. sprawdzania dostępności nazwy użytkownika:
Username input
↓
Wait 300ms
↓
Check availability
Ostateczny przykład łączy stałą initialForm służącą do resetowania, oddzielny stan dla błędów pól, błędów podania i serwera, funkcję validate oraz asynchroniczne wysyłanie danych w formacie JSON, traktujące odpowiedź inną niż OK jako niepowodzenie, resetujące formę przy sukcesie i zawsze usuwające flagę ładowania. Etykiety łączą się z polami za pomocą htmlFor i id.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
aria-invalid na nieprawidłowe pola i połącz komunikaty za pomocą aria-describedby.
Wszystko powyżej mieści się w jednym drzewie decyzji, od danych wejściowych przez walidację po błędy pól lub wywołanie API z gałęziami dla sukcesu i błędu:
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Główne wnioski
- Kontrolowane dane wejściowe sprawiają, że stan staje się źródłem prawdy; jeden obiekt wraz z obsługą typu
[name]: valueobejmuje większość pól. - Liczby przychodzą jako łańcuchy, pola typu checkbox używają atrybutu
checked, elementy wyboru są kontrolowane za pomocą atrybutuselect, a pola do wprowadzania plików zawsze są niekontrolowane. - Walidacja po stronie klienta służy użytkownikowi; tylko walidacja po stronie serwera chroni dane.
- Stan wysyłki modelu jest określany wyraźnie, a przycisk wysyłania jest dezaktywowany podczas wysyłki żądań.
- Dodaj React Hook Form i Zod, gdy liczba pól, wspólne reguły lub koszt renderowania sprawiają, że ręczna obsługa stanu jest uciążliwa.
Pozycje pokrewne
- Formy React zasilane schematem: renderowanie i walidacja na podstawie JSON Schema — Jak renderować zwalidowane formy React bezpośrednio z JSON Schema, obsługiwać elementy $ref, oneOf oraz ścieżki if/then, włączać niestandardowe elementy interfejsu i unikać częstych pułapek walidacyjnych.
- Audyt utils.js: Sześć wbudowanych funkcji zastępujących ręcznie napisane pomocniki — Dowiedz się, które natywne API JavaScript mogą zastąpić metody takie jak głębokie klonowanie, kopie obronne, ręczne timeouty, odroczone obietnice oraz lodash groupBy, oraz jakie są związane z tym ograniczenia.