Карта состояния и формы: RHF, zod, i18n
К этому уроку Echo собран, оживлён хуками и покрыт тестами. Но если открыть его
код и выписать всё, что меняется во времени, картина получится пёстрая.
Активный канал лежит в useState, тема в useState плюс localStorage,
сообщения грузит useMessages, пользователь живёт в AuthContext, поисковый
запрос снова в useState. Всё это называется одним словом, состояние, но ведёт
себя по-разному: одно приходит с сервера и устаревает, другое должно попасть в
адресную строку, третье существует ровно пока открыта форма. Перед тем как в
следующем уроке переезжать на стейт-менеджер, надо понять, какого состояния
у нас на самом деле пять видов и где каждому место. А заодно собрать наконец
честные формы /login и /signup: с валидацией, доступностью и переводом.
1. Пять видов состояния
Представь рабочий стол с пятью ящиками. Соблазнительно кидать в один: и счета,
и письма, и список дел. Найти потом нужное мучительно. С состоянием в приложении
ровно так же. Когда всё лежит в useState, любой вопрос (откуда это взялось,
кто это меняет, переживёт ли перезагрузку) требует читать весь компонент.
Разложи по ящикам, и ответ становится очевиден из того, где состояние лежит.
Видов пять, и у каждого свой дом. Чтобы определить вид, задай состоянию один вопрос:
Откуда взялось это состояние?
пришло с сервера, может устареть ─────────────► server state (RTK Query, урок 11)
должно жить в адресе (шаринг, кнопка назад) ──► route state (URL, router)
это поле формы (значение, ошибка, submit) ────► form state (react-hook-form)
это стадия операции (idle, идёт, готово, сбой)► process state (размеченный union)
всё остальное (тема, тогглы, активный таб) ───► UI state (useState, Context, RTK)
Разберём по одному.
Server state это данные, которые принадлежат серверу, а у тебя лишь копия. Сообщения, каналы, профиль. Их особенность: они устаревают сами по себе, без твоего участия, потому что источник правды на сервере. Им нужен кэш, а не просто переменная. В Echo эту роль играет RTK Query из урока 11, в проектах без Redux ту же роль закрывают TanStack Query (бывший React Query) и SWR, та же связка кэш плюс ключ плюс инвалидация, без привязки к Redux.
Route state это то, что должно жить в URL: активный канал, поисковый запрос,
открытый фильтр, номер страницы. Признак простой: если состоянием хочется
поделиться ссылкой или оно должно пережить кнопку назад, его место в адресе.
URL это тоже хранилище, просто про него часто забывают и дублируют значение
ещё и в useState, получая рассинхрон.
Form state это жизнь конкретной формы: значения полей, ошибки валидации,
тронуто ли поле, идёт ли отправка. Оно живёт ровно пока форма открыта и обычно
никому за её пределами не нужно. Этим займётся react-hook-form, разделы 2 и 3.
Process state
это стадия дела во времени: idle, идёт отправка, успех, сбой. Его легко спутать
с булевыми флагами (isLoading, isError), но флаги допускают невозможные
сочетания (что значит isLoading && isError?). Лучше один размеченный union из
04-ts:
type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; message: string };
Здесь message существует только в ветке error, и компилятор не даст прочитать
его в других. Невозможное состояние стало невыразимым. Полноценные конечные
автоматы для UI ждут в уроке 14.
UI state это всё остальное чисто клиентское: тема, открыт ли drawer, активный
таб, развёрнут ли блок. Маленькое и локальное живёт в useState, общее для
многих компонентов переедет в стейт-менеджер. В Echo это будет Redux Toolkit
(урок 10) и Reatom (урок 12), но есть и более лёгкие альтернативы:
Zustand
(маленький store на хуках без редюсеров) и
Jotai
(атомы, идея как у Reatom). Палитра богаче, чем “Redux или ничего”.
Что взять с собой
- Слово состояние прячет пять разных вещей: server, route, form, process, UI.
- Вид определяется одним вопросом: откуда оно взялось и кому принадлежит.
- Server state это кэш, route state это URL, form state живёт в форме, process state это размеченный union, UI state это остальное локальное.
2. react-hook-form: форма без лишних ререндеров
В уроке 03 мы делали формы на голом DOM и Constraint Validation API. Это
работает, но на каждое поле руками вешать обработчик и хранить значение
утомительно. В React возникает вторая проблема: если каждое поле это
controlled-инпут
со своим useState, то каждое нажатие клавиши перерисовывает форму целиком.
react-hook-form решает обе боли. Поля он держит неуправляемыми (через ref), а компонент подписывает только на то, что тому нужно. Ядро это один хук:
import { useForm } from 'react-hook-form';
type LoginForm = { email: string; password: string };
const {
register, // подключает поле к форме по имени
handleSubmit, // валидирует и зовёт твой обработчик с готовыми значениями
formState: { errors, isSubmitting }, // ошибки и стадия отправки
} = useForm<LoginForm>();
register('email') возвращает набор пропсов (name, ref, onChange,
onBlur), которые ты разворачиваешь в <input>. Дальше форма сама следит за
полем. handleSubmit оборачивает твой обработчик: сначала прогоняет валидацию,
и только если она прошла, зовёт обработчик с типизированными значениями.
const onSubmit = handleSubmit(async (values) => {
// сюда попадём только если валидация прошла
// values имеет тип LoginForm, без any и без ручных проверок
await signIn(values);
});
return (
<form onSubmit={onSubmit} noValidate>
<input type="email" {...register('email')} />
<input type="password" {...register('password')} />
<button type="submit" disabled={isSubmitting}>
Войти
</button>
</form>
);
Обрати внимание на две детали. disabled={isSubmitting} это тот самый process
state из раздела 1: RHF сам отслеживает стадию отправки, и кнопка гаснет, пока
запрос в полёте, без отдельного useState. А noValidate на форме отключает
нативные браузерные подсказки: мы хотим показывать ошибки своим UI, а валидацию
вести через схему, к которой переходим.
Когда поле это не обычный <input>, а сторонний UI-компонент (Select,
DatePicker), он часто не умеет работать с ref. Для таких случаев есть мост
Controller:
import { Controller } from 'react-hook-form';
<Controller
name="channel"
control={control} // control тоже из useForm
render={({ field }) => <ChannelSelect value={field.value} onChange={field.onChange} />}
/>;
Controller делает компонент управляемым там, где иначе не получится, и не
трогает остальную форму. Правило простое: обычные инпуты через register, чужие
виджеты через Controller.
Что взять с собой
- RHF держит поля неуправляемыми, поэтому набор текста не перерисовывает форму.
register(name)подключает поле,handleSubmitвалидирует и зовёт обработчик.isSubmittingизformStateэто готовый process state для блокировки кнопки.- Обычные поля через
register, сторонние виджеты черезController.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.