Главная / Статьи

Разработка форм с использованием React вручную: управляемые поля ввода, проверка данных и состояния отправки

Узнайте, как работают формы в React: от контролируемых полей ввода и обработчиков для разных типов данных до проверки данных, состояний отправки, динамических полей, загрузки файлов и ситуаций, когда библиотека форм оправдывает себя.

Издатель: REACTAPP.TOP13 мин чтения2992 слов

Источники: React Forms: A Complete Guide from Basics to Production Patterns

Вход в систему, оформление заказа, поиск, настройки профиля, опросы: практически каждое приложение строится вокруг форм. В React форма означает управление значениями, проверкой данных, сообщениями об ошибках, отправкой данных, состоянием загрузки и ошибками сервера, добавлением полей во время выполнения программы, сбросом данных и оптимизацией производительности. В этом руководстве пошагово показано, как создавать такие формы — от управляемых полей ввода до полноценной формы регистрации без использования сторонних библиотек, а также когда целесообразно использовать React Hook Form и Zod.

Как форма работает в приложении React

Форма собирает информацию от пользователя, как на этом экране регистрации:

┌──────────────────────────────┐
│       Create Account         │
│                              │
│ Name     [______________]    │
│ Email    [______________]    │
│ Password [______________]    │
│                              │
│         [ Register ]         │
└──────────────────────────────┘

React связывает эти поля ввода с логикой приложения, и практически каждая форма проходит через один и тот же жизненный цикл:

User Input
    ↓
Form State
    ↓
Validation
    ↓
Submit
    ↓
API Request
    ↓
Success / Error

Управляемые компоненты: состояние как источник истины

В управляемом компоненте значение поля ввода value берется из состояния React, а любые изменения записываются обратно через метод onChange.

import { useState } from "react";

function LoginForm() {
  const [email, setEmail] = useState("");

  return (
    <input
      value={email}
      onChange={(event) => {
        setEmail(event.target.value);
      }}
    />
  );
}

Установка value={email} означает, что React сам определяет, что будет отображаться в поле. Каждая нажатие клавиши проходит через этот цикл:

User types
    ↓
onChange fires
    ↓
setEmail()
    ↓
React state updates
    ↓
Component renders
    ↓
Input receives new value

Поскольку отображаемое значение всегда получается из состояния, состояние является единственным источником правды.

Название «управляемый» отражает тот факт, что поле ввода не контролирует свое собственное значение:

<input
  value={email}
  onChange={handleChange}
/>

Данные передаются от состояния к элементу, затем через ввод пользователя и обработчик, и снова обратно в состояние:

             React State
                  │
                  ▼
              input value
                  │
                  ▼
              User types
                  │
                  ▼
              onChange
                  │
                  ▼
             React State

Один объект состояния для нескольких полей

Использование вызова useState для каждого поля приводит к плохой масштабируемости:

const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

Более просто обслуживать один объект, ключом к которому является имя поля. В этой форме три поля находятся в одном объекте, они делит один метод handleChange, и данные записываются при отправке формы.

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>
  );
}

Ключевой строкой является вычисляемое свойство [name]: value. Атрибут name каждого поля совпадает с ключом состояния, поэтому один обработчик обновляет то поле, которое было активировано.

Для текстовых полей обработчик считывает данные из элемента, связанного с событием:

<input
  value={name}
  onChange={(event) => {
    setName(event.target.value);
  }}
/>

event.target — это само поле ввода, поэтому event.target.value содержит его текущий текст:

User types: Salim

event.target.value
       ↓
"Salim"

Правильная обработка каждого типа поля ввода

Формы содержат элементы для ввода текста, пароля, чисел, полей с флажками, радио-кнопок, выпадающих списков, текстовых полей большого размера, загрузки файлов, указания даты и диапазонов. Большинство из них работают по одному и тому же принципу, с небольшими важными отличиями.

Текст, пароль и числа

Поле текста связывает value с его ключом:

<input
  type="text"
  value={form.name}
  onChange={handleChange}
/>

Поле пароля отличается только параметром type:

<input
  type="password"
  value={form.password}
  onChange={handleChange}
/>

То же самое касается поля чисел:

<input
  type="number"
  value={form.age}
  onChange={handleChange}
/>

Проблема в том, что event.target.value всегда является строкой даже при настройке type="number". В приведённом ниже фрагменте смешаны код и пояснения; суть в том, что получается значение вида "25", а не 25, и для преобразования требуется использовать функцию Number().

const age = event.target.value;

may produce:

"25" rather than: 25

If you need a number:

const age = Number(event.target.value);

Обратите внимание на пустые значения: Number("") возвращает 0, поэтому многие формы сохраняют исходную строку и преобразуют её только при проверке или отправке формы.

Для полей с флажком используется свойство checked

Для поля с флажком свяжите свойство checked с логическим значением и читайте значение через event.target.checked:

<input
  type="checkbox"
  checked={form.agree}
  onChange={(event) => {
    setForm((previous) => ({
      ...previous,
      agree: event.target.checked,
    }));
  }}
/>

event.target.value для полей типа checkbox возвращает значение атрибута value (по умолчанию "on"), а не информацию о том, отмечено ли поле.

Группы элементов select, textarea и radio

Элемент select управляется через атрибут value; пустой первый вариант используется в качестве местохождения.

<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 элемент textarea рассматривается как обычное поле ввода, у которого есть свойство value вместо текста внутри элемента.

<textarea
  name="message"
  value={form.message}
  onChange={handleChange}
/>

Ползунки выбора в одной группе имеют общее свойство name. Каждый из них отмечается, когда его значение совпадает со состоянием приложения, а общий обработчик сохраняет выбор в 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>

Перехват процесса отправки формы

По умолчанию браузер сам отправляет форму и может перейти на другую страницу или перезагрузить её. В приложениях React обработка отправки осуществляется с помощью JavaScript, поэтому обработчик отправки начинается со строки event.preventDefault(), как в примере регистрации. Далее логика развивается по вашему усмотрению:

Submit
  ↓
preventDefault()
  ↓
Validate
  ↓
API
  ↓
Update UI

Проверка корректности перед запросом

Проверки корректности определяют, является ли введённый контент приемлемым. В первой версии ошибки собираются по ключам полей:

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;
};

Обработчик отправки сначала проверяет данные, сохраняет ошибки и прерывает работу при их наличии:

const handleSubmit = (event) => {
  event.preventDefault();

  const validationErrors = validate();

  if (Object.keys(validationErrors).length > 0) {
    setErrors(validationErrors);
    return;
  }

  // Submit form
};

Ошибки хранятся в отдельном состоянии:

const [errors, setErrors] = useState({});

В определённый момент оно может содержать:

{
  email: "Invalid email",
  password: "Password must contain at least 8 characters"
}

Каждое поле отображает соответствующее сообщение условно:

{errors.email && (

  <p>{errors.email}</p>

)}

Лучшая реализация функции validate принимает форму в качестве аргумента, что делает её чистой и поддающейся тестированию, а также проверяет формат и длину данных только при наличии поля:

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;
}

Обработчик вызывает его таким же образом:

const errors = validate(form);

if (Object.keys(errors).length > 0) {
  setErrors(errors);
  return;
}

Проверка includes("@") выявляет только очевидные опечатки; настоящим подтверждением работоспособности адреса является письмо с подтверждением.

Проверка на стороне клиента и сервера выполняют разные задачи

Проверка на стороне клиента происходит в браузере и обычно включает:

  1. Обязательные поля
  2. Формат электронной почты
  3. Минимальная длина пароля
  4. Подтверждение пароля

Она обеспечивает быструю обратную связь, улучшает пользовательский опыт и сокращает количество ненужных запросов, но не является барьером безопасности. Сервер должен повторить проверки и ответить на вопросы, которые браузер не может решить:

  1. Зарегистрирована ли эта почта уже?
  2. Имеет ли пользователь права на доступ?
  3. Валиден ли цена?
  4. Может ли этот пользователь выполнять данную операцию?

Безопасные приложения используют оба подхода. Никогда не пишите логику в таком виде:

if (clientValidationPassed) {
  // Data is safe
}

Проверки браузера можно обойти путем редактирования страницы или прямого вызова API, поэтому бэкенд самостоятельно проверяет каждый запрос:

Request
  ↓
Authentication
  ↓
Authorization
  ↓
Validation
  ↓
Business Logic
  ↓
Database

Моделирование состояний отправки

Форма в производственной среде проходит через определённые состояния, которые должен отражать интерфейс:

Idle
 ↓
Submitting
 ↓
Success

or

Idle
 ↓
Submitting
 ↓
Error

Два элемента состояния отслеживают их:

const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");

Асинхронный обработчик устанавливает флаг, очищает старые ошибки сервера, ожидает запрос, записывает понятное сообщение при сбое и в любом случае сбрасывает флаг в блоке finally.

const handleSubmit = async (event) => {
  event.preventDefault();

  setIsSubmitting(true);
  setServerError("");

  try {
    await createUser(form);
  } catch (error) {
    setServerError("Unable to create account");
  } finally {
    setIsSubmitting(false);
  }
};

Отключение и переименование кнопки во время запроса предотвращает дублирование отправок:

<button
  type="submit"
  disabled={isSubmitting}
>
  {isSubmitting
    ? "Creating..."
    : "Create Account"}
</button>

Цикл жизни кнопки совпадает с циклом обработки запроса:

User clicks Submit
      ↓
Request starts
      ↓
Button disabled
      ↓
Request finishes
      ↓
Button enabled

Контролируемые и неконтролируемые поля ввода

При использовании контролируемого ввода состояние React хранит значение. В приведённом ниже блоке показана сокращённая версия, полный компонент и поток данных, разделённые строками из звёздочек.

<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

При использовании неконтролируемого ввода DOM хранит значение, а React считывает его через ref, обычно при отправке формы, не сохраняя каждую нажатую клавишу:

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

Используйте контролируемые вводы для мгновенной проверки, условного отображения интерфейса, вычисления производных значений, форматирования, управления состоянием через React или реализации пользовательских интеракций. Поскольку React знает значение, интерфейс может мгновенно реагировать:

{password.length < 8 && (
  <p>Password is too short</p>
)}

Выбирайте неуправляемые поля ввода для простых форм, когда отслеживание состояния при каждом нажатии клавиши не приносит пользы, когда требуется меньше обновлений, или когда библиотека сама управляет ссылками на данные. Действия форм в React 19 также основаны на этой модели; см. асинхронные формы с useActionState и useOptimistic.

Когда библиотека для форм оправдывает себя

React Hook Form

Ручная настройка десятков полей — утомительно. Функция register из React Hook Form связывает поле ввода и задаёт для него правила, handleSubmit проверяет данные перед вызовом вашей функции обратного вызова, а formState.errors отображает сообщения об ошибках.

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>
  );
}

Это упрощает регистрацию, валидацию, обработку ошибок, отправку данных, сброс настройек, работу с динамическими полями и состоянием формы. Поскольку по умолчанию регистрируются все поля, это позволяет избежать перерисовки всей формы при каждом нажатии клавиши, в отличие от примитивных форм с контролем.

Валидация схемы с помощью Zod

По мере увеличения количества правил разрозненные операторы if становятся менее удобными в использовании. Zod позволяет описать ожидаемую структуру данных в одном объявлении:

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"),
});

Часто в производственных приложениях библиотека используется для управления состоянием, а схема — для определения правил:

React Hook Form
       +
      Zod
       ↓
Form state + validation

Схема может быть представлена в таком компактном виде:

const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

React Hook Form позволяет подключать схему через резолвер, что удобно для крупных форм, правила которых должны оставаться последовательными.

Динамические, условные и зависимые поля

Повторяющиеся группы

Пользователи часто добавляют записи по мере необходимости, например информацию об образовании:

Education

Degree  [________]
College [________]

[ + Add Education ]

Храните их в виде массива объектов:

const [education, setEducation] = useState([
  {
    degree: "",
    college: "",
  },
]);

При добавлении строки в конец добавляется пустой объект:

setEducation((previous) => [
  ...previous,
  {
    degree: "",
    college: "",
  },
]);

Отображение осуществляется путем просмотра элементов массива:

{education.map((item, index) => (
  <div key={index}>
    <input
      value={item.degree}
      onChange={(event) => {
        // update degree
      }}
    />

    <input
      value={item.college}
      onChange={(event) => {
        // update college
      }}
    />
  </div>
))}

Индекс key является надежным только тогда, когда строки никогда не удаляются и не перестраиваются; в противном случае используйте стабильные идентификаторы, иначе текст с определенным типом может быть привязан к неверной строке.

Условные поля

Некоторые поля зависят от предыдущего выбора, например, данные компании для бизнес-аккаунта:

Account Type

○ Individual
○ Business

If the user chooses:

Business

show:
Company Name
GST Number
Company Address

Для этого используется условное выражение:

{form.accountType === "business" && (
  <>
    <input
      name="companyName"
      value={form.companyName}
      onChange={handleChange}
    />

    <input
      name="gstNumber"
      value={form.gstNumber}
      onChange={handleChange}
    />
  </>
)}

Если пользователь вернется назад, очистите или исключите скрытые значения, чтобы не отправлялись устаревшие данные.

Каскадные выборы

Страна, штат и город образуют цепочку:

Country
   ↓
State
   ↓
City

Изменение на более высоком уровне сбрасывает все элементы ниже него перед загрузкой новых опций:

Country changes
      ↓
Clear State
      ↓
Fetch states
      ↓
User selects State
      ↓
Fetch cities

Поскольку параметры поступают с сервера, TanStack Query хорошо сочетается с библиотекой форм для кэширования и управления состоянием загрузки.

Загрузка файлов

Полей ввода файлов нельзя управлять напрямую; считайте файл из event.target.files:

<input
  type="file"
  onChange={(event) => {
    const file = event.target.files?.[0];

    if (file) {
      console.log(file);
    }
  }}
/>

Чтобы загрузить файл, отправьте тело запроса в формате FormData:

const formData = new FormData();

formData.append("name", form.name);
formData.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  body: formData,
});

Не устанавливайте значение Content-Type: multipart/form-data вручную с помощью fetch и FormData; браузер сам генерирует этот заголовок с необходимыми маркерами разделения.

Поддержание высокой скорости работы крупных форм

Крупные, сложные формы создают нагрузку на примитивные формы, которые перерисовываются при каждом нажатии клавиши:

User types
   ↓
setState
   ↓
Render
   ↓
Large form tree

Полезные меры противодействия:

  1. Храните состояние локально, а не глобально без особой необходимости.
  2. Разделите форму на секции, чтобы при каждой изменении перерисовывалось меньше элементов:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
  1. Используйте подходящую библиотеку, такую как React Hook Form.
  2. Избегайте тяжелых вычислений или вызовов API на каждый символ.
  3. Ограничьте выполнение операций с задержкой, например, проверки наличия имени пользователя:
Username input
     ↓
Wait 300ms
     ↓
Check availability

Полная форма без библиотек

В примере используется константа initialForm для сброса данных, отдельное состояние для ошибок полей, ошибок отправки и сервера, функция validate, а также асинхронная отправка данных в формате JSON; ответы, отличные от OK, считаются ошибками, данные сбрасываются при успехе, а флаг загрузки всегда сбрасывается. Метки связаны с полями с помощью атрибутов htmlFor и 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 на невалидные поля и свяжите сообщения с помощью атрибута aria-describedby.

Общий порядок действий

Всё вышеперечисленное вписывается в одну дерево решений: от ввода данных через проверку до обработки ошибок полей или вызова API с ветками для успешного и неудачного результата:

                    React Form
                        │
                        ▼
                   User Input
                        │
                        ▼
                    Form State
                        │
                        ▼
                    Validation
                   /          \
                Valid        Invalid
                  │              │
                  ▼              ▼
               Submit        Show Errors
                  │
                  ▼
               API Call
              /        \
          Success      Error
             │           │
             ▼           ▼
        Reset/Form    Show Server
        Success UI       Error

Основные выводы

  • Контролируемый ввод делает состояние источником правды; один объект вместе с обработчиком вида [name]: value покрывает большинство полей.
  • Числа поступают в виде строк, ползунки используют атрибут checked, списки управляются через элемент select, а поля для загрузки файлов всегда остаются неконтролируемыми.
  • Проверка на стороне клиента направлена на удобство пользователя; только проверка на сервере защищает данные.
  • Состояние отправки модели указывается явно, и кнопка отправки отключается во время запросов.
  • Используйте React Hook Form и Zod, когда количество полей, общие правила или затраты на отрисовку делают ручное управление состоянием сложным.