Перейти к содержанию

Управляемая форма и submit

Маршрут /new пока показывает заглушку. Превратим его в форму для новой учебной карточки: заголовок, текст, тема и время чтения. На этом этапе submit только готовит локальный нормализованный снимок. POST появится в следующем уроке, поэтому можно разобраться с вводом, не смешивая его с ожиданием сети.

Новая запись должна иметь ту же модель, что и существующий каталог. Пользователь задаёт четыре поля, а id позднее назначит сервер. Мы не предлагаем ручной ввод идентификатора: адрес карточки является результатом создания, а не частью свободного текста формы.

Данные формы живут в состоянии

Draft содержит строки title, body и minutes, а также значение topic из нашего словаря. Даже числовой input получает строковое состояние. Пользователь может временно удалить содержимое поля, и пустая строка является допустимым промежуточным состоянием ввода, хотя не является допустимым количеством минут для готовой карточки.

Каждый элемент получает value и синхронный onChange. Без обработчика значение станет управляемым, но пользователь не сможет его менять. Если начать с undefined, а затем передать строку, режим элемента изменится. Поэтому начальные title и body равны пустой строке, а minutes — строке "15". Поведение управляемых элементов описано в документации React.

Полный src/pages/CreateNote.jsx:

import {useState} from 'react';
import {topics} from '../lib/models.js';
import {normalizeDraft} from '../lib/form.js';
export default function CreateNote() {
  const [draft, setDraft] = useState({title: '', body: '', topic: 'html', minutes: '15'});
  const [preview, setPreview] = useState(null);
  const [error, setError] = useState('');
  function change(field, value) {setDraft(current => ({...current, [field]: value})); setPreview(null);}
  function submit(event) {
    event.preventDefault();
    try {setPreview(normalizeDraft(draft)); setError('');}
    catch (cause) {setError(cause.message);}
  }
  return <section><h1>Новая карточка</h1><form onSubmit={submit}>
    <label htmlFor="note-title">Заголовок<input id="note-title" value={draft.title} onChange={event => change('title', event.target.value)} required minLength={3} maxLength={120} /></label>
    <label htmlFor="note-body">Текст<textarea id="note-body" value={draft.body} onChange={event => change('body', event.target.value)} required minLength={20} maxLength={2000} /></label>
    <label htmlFor="note-topic">Тема<select id="note-topic" value={draft.topic} onChange={event => change('topic', event.target.value)}>{topics.map(topic => <option key={topic} value={topic}>{topic}</option>)}</select></label>
    <label htmlFor="note-minutes">Минуты<input id="note-minutes" type="number" min="5" max="180" step="1" value={draft.minutes} onChange={event => change('minutes', event.target.value)} required /></label>
    <button type="submit">Подготовить карточку</button>
    {error && <p role="alert" className="error">{error}</p>}
  </form>{preview && <section className="notice"><h2>Локальный снимок</h2><p>{preview.title} · {preview.minutes} минут</p><p>Запрос записи ещё не отправлен.</p></section>}</section>;
}

Функция change создаёт новый объект через spread и заменяет одно поле. Существующий draft не мутируется. Computed property [field] означает имя изменяемого поля, которое передаёт конкретный обработчик. Мы используем фиксированные имена из собственного JSX, а не принимаем произвольный объект полей извне.

Почему не обрезаем текст на каждом символе

Пробелы между словами нужны пользователю в процессе ввода. Если выполнять trim внутри каждого onChange, конечный пробел исчезнет и человеку будет трудно продолжить предложение. Мы сохраняем введённую строку, а нормализуем её при submit. Это разделяет удобство редактирования и требования к готовому payload.

После изменения поля прежний preview сбрасывается. Иначе под формой мог бы остаться снимок «Сетка», пока в input уже написан другой заголовок. Пользователь должен понимать, к какому состоянию относится подготовленный результат. Наличие preview не означает сохранения на сервере.

Подписи связаны с элементами через htmlFor и id. Тема использует select, потому что модель разрешает только html, css и javascript. Свободный input для темы позволил бы ввести значения, которых сервер не знает. Отображение английских идентификаторов пока учебное; перевод подписей можно выполнить отдельно без изменения значений option.

Нормализуем только готовый снимок

Добавим полный src/lib/form.js:

import {topics} from './models.js';
export function normalizeDraft(draft) {
  const title = draft.title.trim();
  const body = draft.body.trim();
  const minutes = Number(draft.minutes);
  if (title.length < 3 || title.length > 120 || body.length < 20 || body.length > 2000 ||
      !topics.includes(draft.topic) || !Number.isInteger(minutes) || minutes < 5 || minutes > 180) {
    throw new Error('Проверьте заголовок, текст, тему и время от 5 до 180 минут.');
  }
  return {title, body, topic: draft.topic, minutes};
}

Number преобразует строку минут, затем Number.isInteger и границы проверяют результат. Пустая строка преобразуется в ноль и не проходит минимум пять. Дробное значение не проходит проверку целого числа. Сам type="number" не освобождает JavaScript-код от проверки готовых данных.

Заголовок после trim должен содержать от трёх до ста двадцати единиц длины JavaScript-строки, текст — от двадцати до двух тысяч. Мы используем string.length, а не подсчёт слов или видимых графем. Составной символ и некоторые эмодзи могут занимать несколько единиц. Это техническое правило модели, которое не следует выдавать за универсальную редакторскую норму.

Ввод Нормализованный результат Решение
Заголовок с внешними пробелами Пробелы по краям убраны Допустим при нужной длине
Минуты "15" Число 15 Допустимо
Минуты "" Число 0 Отклонено
Минуты "12.5" Число 12.5 Отклонено
Тема "react" Не входит в словарь Отклонено

Словарь намеренно не расширяется только потому, что курс посвящён React. Это тот же маленький каталог HTML, CSS и JavaScript. Добавление темы потребует согласованного изменения клиента, проверки модели, server normalize и исходных данных. Одно новое option не изменит серверный договор.

Submit работает для формы целиком

Обработчик установлен на form, а кнопка имеет type="submit". Поэтому отправка через предусмотренное действие формы попадает в одну функцию. Event.preventDefault прекращает обычное действие браузера, которое в этом клиентском сценарии не должно загружать новый документ с данными формы.

Если поставить проверку только на onClick кнопки, другие способы submit легко окажутся вне логики. Если забыть preventDefault, страница может перезагрузиться и потерять локальный draft. React не отменяет поведение HTML-форм автоматически: нужное решение принимает наш обработчик.

NormalizeDraft либо возвращает готовый объект, либо бросает понятное исключение. В успешной ветке setPreview получает именно нормализованный payload. В ошибочной ветке пользователь видит пояснение, а введённые строки сохраняются. Мы не очищаем поля, когда человек ошибся в длительности, потому что остальные данные всё ещё полезны.

Время чтения не вычисляется по длине body автоматически. Оно является отдельным учебным полем, которое вводит автор карточки. Если позднее потребуется оценка по числу слов, нужно будет описать алгоритм и возможность ручной поправки, а затем решить, остаётся ли minutes входным полем. Сейчас незаметное вычисление при каждом onChange лишь добавило бы неожиданные изменения к редактированию текста.

Две границы проверки

Атрибуты required, minLength, maxLength, min и max помогают браузеру обнаружить часть неверного ввода. NormalizeDraft повторяет договорённость на стороне приложения. Сервер также проверит payload независимо, потому что HTTP-запрос можно создать без этой формы. Эти слои имеют разные роли и не заменяют друг друга.

Например, строка из одних пробелов может удовлетворить отдельную формальную проверку длины до trim, но нормализатор её отклонит. И наоборот, пользовательский интерфейс может показать допустимые опции, однако изменённый вручную запрос всё равно обязан пройти серверный словарь тем.

Не нужно отправлять id, status или HTML-элемент целиком. Возвращаемый объект содержит ровно четыре поля модели. Это облегчает чтение будущего POST и позволяет сравнить подтверждённую запись с подготовленным снимком.

После самостоятельного запуска заполненная допустимая форма должна показать «Локальный снимок» с явным сообщением, что запись ещё не отправлена. Каталог от такого действия не увеличится. Пример не исполнялся при подготовке курса. Следующий урок заменит локальное подтверждение на серверное и отдельно разберёт случаи, когда результат записи неизвестен.

Оглавление серии