Главная / Статьи / Форма с проверенной датой рождения в Next.js с контролируемыми полями ввода и обратными вызовами

Форма с проверенной датой рождения в Next.js с контролируемыми полями ввода и обратными вызовами

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

2384 слов

Для того чтобы приложение по гороскопам смогло составить прогноз, оно нуждается в двух данных от пользователя: имени и дате рождения. Кажется, что это форма на пять минут, но даже такой простой элемент требует серьезных дизайнерских решений: где она будет работать в приложении Next.js, кому принадлежат введенные данные, как отклонять недействительные даты и кто будет решать, что происходит после успешной отправки.

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

Определите, за что отвечает компонент

Прежде чем писать любой JSX-код, стоит перечислить задачи компонента. У этой формы их ровно три:

  1. Хранить введенные пользователем данные.
  2. Проверять ввод перед его отправкой.
  • Возвращаем валидные данные обратно родительскому компоненту.
  • Всё, что не входит в этот список, например вызов API или отображение полученных данных, должно находиться в другом месте. Короткий список делает остальную часть дизайна проще для понимания.

    Почему форма должна быть клиентским компонентом

    В App Router каждый компонент является серверским компонентом, если только вы не выберете иное. Серверские компоненты отрисовываются на сервере и не содержат интерактивного JavaScript, поэтому они не могут хранить состояние или реагировать на события. Поэтому форма начинается с директивы client.

    "use client";
    

    Компонент зависит от нескольких элементов, существующих только в браузере:

    • useState для хранения текущих значений,
    • обработчики onChange для полей ввода,
    • обработчик onSubmit для формы,
    • постоянное взаимодействие с пользователем,
  • проверка корректности, которая выполняется перед отправкой любых данных.
  • Короче говоря, компонент не просто отображает информацию; он должен реагировать на действия пользователя. Именно это служит признаком для отнесения его к клиентскому компоненту. Хорошей практикой является сделание таких компонентов маленькими и расположение их на концах структуры, чтобы директива не включала большие части страницы в клиентский пакет.

    Ввод данных и атрибутов

    Форма импортирует общий тип Profile вместе с useState.

    import { Profile } from "../types";
    import { useState } from "react";
    

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

    export type Profile ={
     name: string;
     dob: string;
    }
    

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

    type HoroscopeFormProps = {
      onSubmit: (info: Profile) => void;
    };
    

    Пусть родитель решает, что делать дальше

    Именно здесь рисуется граница компонента. Форма собирает данные, но не имеет права решать, что с ними делать. В зависимости от экрана родительский компонент может:

    • обратиться к API,
    • сгенерировать гороскоп,
    • сохранить профиль,
    • показать результат,
    • обновить другое состояние.

    Жесткое задание любого из этих вариантов прямо в форме свяжет её с одним экраном. Вместо этого использование функции onSubmit позволяет сохранять её универсальность. По описанию функция onSubmit принимает объект типа Profile и ничего не возвращает (void); форма вызывает её и продолжает работу. Общий алгоритм выглядит следующим образом:

    User enters information
            ↓
    HoroscopeForm collects it
            ↓
    HoroscopeForm validates it
            ↓
    onSubmit(user)
            ↓
    Parent decides what happens next
    

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

    Хранение введённых данных в состоянии React

    Форма нуждается в месте для хранения текущих значений. Для этого достаточно трех элементов состояния.

    const [name, setName] = useState<string>("");
    const [dob, setDob] = useState<string>("");
    const [error, setError] = useState<string>("");
    

    Сначала обратите внимание на имя.

    const [name, setName] = useState<string>("");
    

    useState возвращает пару. name — это текущее значение, а setName — функция, которую вызывают для его замены; она также запускает повторную отрисовку. Изначальное значение — пустая строка, поскольку ничего ещё не было введено. Использование строки в качестве начального значения вместо undefined имеет значение для управляемых полей ввода: React выдает предупреждение, если поле переходит из режима неконтролируемого в контролируемый при изменении его value с undefined на строку.

    Дата рождения следует тому же принципу.

    const [dob, setDob] = useState<string>("");
    

    Последний элемент хранит текущее сообщение об ошибках.

    const [error, setError] = useState<string>("");
    

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

    Храните проверку даты в отдельной функции

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

    function validateDOB(dob: string): string | null {
    

    Существует ровно два возможных результата: недействительная дата возвращает строку с описанием проблемы; действительная дата возвращает null.

    Valid date
       ↓
    return null
    
    Invalid date
       ↓
    return error message
    

    Поскольку функция отвечает на один вопрос — «Является ли эта дата рождения приемлемой?» — её легко читать, легко тестировать в режиме единиц без отображения какой-либо информации, а также легко использовать на сервере при последующей верификации. Возвращение сообщения вместо выбрасывания исключений делает код, его вызывающий, простым: достаточно проверить результат и отобразить его при наличии.

    Какие даты следует отклонить

    Для даты рождения существуют два разумных правила:

    • Отсутствие будущих дат. Человек не мог родиться в день, который ещё не наступил.
    • Разумный нижний предел. Даты, относящиеся к периоду более 150 лет назад, отклоняются, поскольку с высокой вероятностью это ошибка ввода.

    Дата кажется простой, пока не вступает в игру время суток. Тег <input type="date"> возвращает строку в формате YYYY-MM-DD, а оператор new Date("2024-05-01") интерпретирует эту строку как полночь по UTC, в то время как выражение "today", созданное с помощью new Date(), учитывает местное время — часы, минуты и секунды. В зависимости от часового пояса пользователя простое сравнение может ошибочно считать завтрашний день или отклонить сегодняшний. Два надежных решения — привести обе стороны к началу дня перед сравнением или сравнивать строки в формате YYYY-MM-DD напрямую, поскольку они корректно сортируются как текст. Каким бы ни был ваш подход, и помог ли вам в его разработке ИИ-ассистент или нет, убедитесь, что вы можете объяснить причину каждого сравнения; ошибки с датами скрываются именно в тех строках, которые никто не понял.

    Согласуйте всё в обработчике отправки

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

    1. предотвратить стандартную отправку формы браузером, которая приведёт к перезагрузке или переходу на другую страницу,
    2. проверить, что в обоих полях есть значения,
    3. проверить дату рождения,
    4. отобразить ошибку, если что-то не так,
    5. в противном случае передать данные родительскому компоненту.

    Всё начинается примерно так.

    const handleSubmit = (e: React.SubmitEvent) => {
      e.preventDefault();
    

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

    e.preventDefault();
    

    Начиная с этого момента, именно компонент сам решает, что будет происходить при отправке данных. Примечание к типу события: во многих проектах этот параметр задаётся как React.FormEvent<HTMLFormElement>. Узнайте, какие типы событий отправки поддерживается у установленной версии @types/react, и выберите тот, который постоянно используется в вашем проекте.

    Отклонение пустых полей с помощью раннего возврата

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

    if (!name || !dob) {
      setError("Please enter in information");
      return;
    }
    

    Если любое из полей пустое, обработчик фиксирует ошибку и немедленно возвращается. Это пример шаблона раннего возврата (или клозулы-защитника): как только становится ясно, что входные данные недействительны, дальнейшие действия не требуются, поэтому функция завершается вместо того, чтобы оборачивать оставшуюся логику в ещё один уровень блоков if. Каждый защитник обрабатывает одну ошибку, а «правильный путь» остается простым до конца.

    Проверка даты

    Как только становится известно, что дата существует, она проходит проверку валидатора.

    const dobError = validateDOB(dob);
    

    Результатом является либо сообщение, либо null, поэтому достаточна одна проверка.

    if (dobError) {
      setError(dobError);
      return;
    }
    

    Сообщение означает, что обработчик его отображает и прекращает работу. null означает, что дата пройдена проверку, и выполнение продолжается.

    Передача очищенных данных родительскому компоненту

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

    setError("");
    

    Затем функция обратного вызова родительского элемента получает проверенный профиль.

    onSubmit({ name, dob });
    

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

    Связь логики с маркапом

    Элемент формы связывает отправку данных с обработчиком.

    <form onSubmit={handleSubmit}>
    

    Это указывает React на необходимость выполнения функции handleSubmit каждый раз, когда форма отправляется, будь то нажатие кнопки или клавиши Enter в поле. Далее идет поле с именем.

    <input
      type="text"
      value={name}
      onChange={(e) => setName(e.target.value)}
    />
    

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

    Это контролируемое поле ввода: источником истины для его значения является состояние React, а не DOM. Каждый раз, когда пользователь вводит текст, запускается обработчик изменений.

    onChange={(e) => setName(e.target.value)}
    

    Он считывает новый текст из события и сохраняет его в состоянии. Полный цикл выглядит так:

    User types
        ↓
    onChange fires
        ↓
    setName(new value)
        ↓
    name state updates
        ↓
    value={name}
        ↓
    Input displays updated value
    

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

    <input
      type="date"
      value={dob}
      onChange={(e) => setDob(e.target.value)}
    />
    

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

    Каждому полю ввода также должен соответствовать видимый <label>. Заменителем не может служить местоопределитель или близлежащий заголовок; именно метка сообщается дикторами экрана, а её описание позволяет сделать поле кликабельным.

    Показывайте ошибки только тогда, когда они существуют

    Сообщение об ошибке должно появляться только при наличии ошибки. Для этого используется условная отрисовка.

    {error && (
      <p role="alert">
        {error}
      </p>
    )}
    

    Когда в переменной error хранится текст, параграф отображается; когда там пустая строка, которая считается ложной, ничего не отображается. Здесь использование сокращения && безопасно, поскольку значение является строкой. Однако с числами это может привести к ошибкам: значение 0 будет отображаться как буквальное «0».

    У этого параграфа также есть роль ARIA.

    role="alert"
    

    role="alert" сообщает технологиям вспомогательного доступа о том, что этот контент важен и требует немедленного внимания, поэтому считыватели экрана объявляют о нем сразу по его появлению. Это изменение всего одного атрибута позволяет использовать обратную связь по проверке для людей, которые не могут видеть появляющееся сообщение. Для большей ясности вы также можете отметить проблемное поле атрибутом aria-invalid и связать его с сообщением с помощью атрибута aria-describedby.

    Добавление кнопки отправки

    Последним элементом является кнопка, явно объявленная как кнопка отправки.

    <button type="submit">
      Submit
    </button>
    

    Внутри формы кнопка с атрибутом type="submit" запускает метод onSubmit формы, а вместе с ним и метод handleSubmit. Кнопки внутри формы по умолчанию всё равно предназначены для отправки, но указание типа исключает неожиданности, если позже кто-то добавит вторую кнопку для другой цели, например для очистки полей.

    Полный поток данных

    В целом компонент передаёт данные в одном направлении:

    State
      ↓
    User input
      ↓
    Submit
      ↓
    Validation
      ↓
    Parent callback
    
    • useState хранит введённые пользователем данные.
    • Поле ввода обновляет этот состояние при каждой изменении.
    • Отправка формы запускает метод handleSubmit.
    • handleSubmit проверяет корректность введённых значений.
    • Некорректные данные устанавливают состояние ошибки и прерывают процесс.
    • Корректные данные передаются родительскому компоненту через метод onSubmit, после чего родительский компонент их обрабатывает.

    Что делать дальше

    Такой ручной подход идеален для обучения и вполне подходит для форм с двумя полями. Когда количество полей увеличивается, а также появляются правила между полями или проверки на стороне сервера, стоит рассмотреть использование библиотеки схем, чтобы одни и те же правила применялись как на клиенте, так и на сервере; одним из способов этого является совместное использование схемы Zod как на фронтенде React, так и на бэкенде Node. Проверка на стороне клиента улучшает пользовательский опыт, но никогда не заменяет проверку на сервере, поскольку любой запрос может быть вручную сконструирован.

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

    • Обозначайте интерактивные компоненты только с помощью "use client" и делайте их небольшими по размеру.
    • Предоставьте форме одну задачу: собирать данные, проверять их и передавать дальше. Пусть родительский компонент управляет побочными эффектами с помощью типизированного обратного вызова.
  • Используйте контролируемые поля ввода, инициализированные строками, чтобы значение на экране совпадало с тем, которое вы проверяете.
  • Размещайте правила валидации в чистых функциях, которые возвращают сообщение или null; их легко тестировать и повторно использовать.
  • Аккуратно обращайтесь с датами: нормализуйте время суток или сравнивайте строки в формате YYYY-MM-DD, чтобы избежать ошибок, связанных с часовыми поясами.
  • Используйте операторы раннего возврата, чтобы сделать обработчик отправки простым, а также атрибут role="alert" вместе с соответствующими метками, чтобы ошибки были доступны.
  • Способность объяснить, зачем существует каждая строка кода, особенно те, которые предложил ИИ-ассистент, является частью завершения работы.