Добавление и изменение через действия
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска до сих пор только читала: список рисовал сервер, а страница задачи ходила за одной записью. Теперь появятся две операции, которые пишут, — добавление задачи и перенос карточки в другую колонку. Всё, что приезжает в них из браузера, — данные извне: строка, файл или ничего, поэтому разбор входа стоит раньше базы, отказ возвращается в форму значением, а карточка на экране появляется до ответа сервера.
Всё, что проект умел до сих пор, — это чтение: listTasks() в серверном компоненте и getTask(id) на странице задачи. Запись начинается с двух операций, и обе живут не в компоненте, а в отдельном файле.
| Файл | Что в нём |
|---|---|
lib/tasks.ts | доступ к данным: к listTasks и getTask добавляются createTask и setStatus |
app/board/actions.ts | действия: разбор входа, вызов хранилища, revalidatePath |
app/board/page.tsx | серверный компонент: читает список и отдаёт его клиентскому |
app/board/board.tsx | "use client": форма, наложение и кнопки статуса |
Отдельный файл — не вкусовщина. Действие нужно и серверной странице, и клиентскому компоненту, а "use server" в прологе файла делает вызываемым из браузера каждый его экспорт. Вот каркас обоих действий: тела хранилища здесь только объявлены, потому что песочница выполняет один файл, а не проект.
Три вещи, которые задают весь остаток статьи. Первая: отказ проверки — это возвращённое значение, а не исключение; брошенная ошибка доедет до error.tsx, а в проде превратится в общую фразу. Вторая: второе действие не возвращает ничего — форме-кнопке результат не нужен, ей достаточно, что страница пересоберётся. Третья: id у moveTask стоит первым аргументом, и придёт он не из полей, а из bind.
Действие — это открытый эндпоинт. Разметка вокруг кнопки ничего не решает: проверки живут внутри действия.
Директивы "use client" и "use server" в одном файле не уживаются: первая говорит «этот модуль уезжает в браузер», вторая — «эти функции остаются на сервере». Поэтому клиентская часть доски действия только импортирует.
Второй способ — объявить действие прямо в серверном компоненте, с "use server" первой строкой в теле функции. Для доски он неудобен: одно и то же действие нужно и списку, и странице задачи.
Статус задачи на доске — не строка, а один из трёх литералов. Из формы же приезжает FormDataEntryValue | null: строка, файл или ничего. Прогноз перед запуском: на что здесь пожалуется компилятор — и что при этом напечатает пример?
Отметок будет три, на двух строках. Первая ожидаема: String(raw) даёт string, а setStatus просит одно из трёх слов. Две другие стоят на одной строке — и обе интереснее. Ругается сам includes: у кортежа, замороженного через as const, тип элемента — то же объединение трёх литералов, и метод требует аргумент этого же типа. Проверка, которая должна была доказать, что строка годится, отказывается принимать недоказанную строку.
Третья отметка — на setStatus внутри if, и она объясняет, почему одной проверкой дело не кончается: includes возвращает boolean, а не сужение, и в теле условия значение осталось всё той же string. Сужать умеет предикат из статьи «Свои проверки типа: предикаты is и assert» — функция, которая отвечает не «да/нет», а «да, и это Status».
Обратите внимание на as внутри предиката: он расширяет кортеж до readonly string[], а не сужает что-то до нужного типа. Такое утверждение ничего не прячет — оно лишь разрешает includes принять произвольную строку, и после него проверка остаётся настоящей. Утверждения вида value as Status, наоборот, гасят ровно ту диагностику, ради которой всё и затевалось.
parseTitle устроен так же: сначала typeof отсекает и null (поля нет), и File (поле подменили на type="file"), и только потом строка обрезается и меряется. Наружу уходит размеченное объединение — тот же приём, что и в разборе входных данных из части III.
Правило доски: в базу не попадает ничего, что не прошло через parseTitle или isStatus.
STATUSES нужен не только проверке. Из него же берутся колонки и их порядок, а подписи держит Record<Status, string> — забыть колонку компилятор не даст.
Разбор готов, осталось показать его результат человеку. useActionState хранит то, что вернуло действие, и признак ожидания — этого хватает и на сообщение, и на выключенную кнопку. Тонкость в другом: после отправки React сбрасывает форму, и введённый текст исчезает, если действие не вернуло его обратно.
Прогноз перед запуском: что останется в поле после отказа и что — после успешного добавления?
После отказа в поле стоит «да» — без пробелов, которые мы туда напечатали. Значит, текст не «остался», а приехал: форма сброшена, но defaultValue к моменту сброса уже другой — действие вернуло обрезанное название в состоянии, и разметка подставила его обратно. После успеха действие возвращает { status: "ok" } без текста, defaultValue становится пустым — и поле очищается само, без reset().
Ошибка здесь — обычное значение, поэтому её видно и в разметке, и в типах: state.message существует только в варианте error, и обратиться к нему мимо проверки компилятор не даст. Это тот же разбор вариантов, что был в useReducer, только редьюсер асинхронный и живёт на сервере.
required и minLength из части II остаются: они ловят опечатку до сети и не дают отправить пустую форму. Но действие — открытый адрес, и запрос к нему приходит и мимо браузера, поэтому те же границы повторяются в parseTitle.
Между кликом и новым списком проходит время: запись в базу, пересборка страницы, дорога обратно. Полсекунды экран честно показывает старое — карточки нет, будто кнопка не нажалась. useOptimistic рисует ожидаемое сразу, а когда действие заканчивается, отпускает нарисованное.
Прогноз: сколько карточек в списке через 200 мс после клика и сколько — через 700 мс?
Наложение — не второе хранилище: shown вычисляется из tasks и живёт ровно столько, сколько идёт действие. Поэтому у него есть неприятная изнанка, на которой в проекте спотыкаются все.
Следующий пример — та же доска, но действие честно пишет в базу и не зовёт revalidatePath. Прогноз: что покажет список после того, как действие закончится, и что при этом будет лежать в базе?
Карточка мигнула и пропала, хотя запись прошла. Наложение отпустилось — а источник, из которого рисуется список, остался прежним. В Next.js этот источник обновляет не setState, а revalidatePath внутри действия: он помечает адрес устаревшим, страница собирается заново, и к моменту, когда наложение исчезает, под ним уже лежат новые данные. Без этой строки useOptimistic выглядит как сломанный, хотя сломано соседнее.
Диагноз простой: если карточка появляется и исчезает, а после перезагрузки страницы она на месте — забыли пометить адрес устаревшим.
У нарисованной заранее карточки ещё нет настоящего id — его выдаст база. Временный ключ должен не совпасть ни с соседним черновиком, ни с будущим идентификатором из базы.
Вторая операция устроена иначе: полей для ввода нет, есть кнопка внутри карточки. Форма всё равно нужна — она собирает FormData и зовёт действие, — но состоит из скрытого поля и кнопки. Задача, которую переносим, приходит не полем, а привязкой: move.bind(null, task.id).
Прогноз: что напечатают строки про «Готово» через 200 мс и через 700 мс после клика — и что покажет строка рендера про сервер и экран?
Две последние строки рендера одинаковы, и это хорошая новость: сначала наложение рисует карточку поверх уже обновлённого списка, потом снимается — и на экране ничего не меняется, потому что настоящие данные совпали с нарисованными. Именно так выглядит удачное предсказание: перерисовки не видно.
Наложение объявлено у списка, а не в карточке, и это принципиально. Во-первых, перенос — событие сразу двух колонок: карточка не знает, куда её переставляют. Во-вторых, при переезде карточка размонтируется, а вместе с ней исчезло бы и её собственное состояние — включая наложение, которое как раз и должно пережить этот момент.
Скрытое поле здесь несёт статус, а не идентификатор задачи. Разница не косметическая: поле легко переписать в инспекторе, поэтому статус проходит через isStatus, а id вообще не едет из браузера — он замкнут в привязке на сервере. Что бывает, когда про bind забывают, компилятор показывает сам.
Диагностика заканчивается неожиданно вывернутой фразой: «Целевая подпись предоставляет слишком мало аргументов. Ожидал 2 или больше, но получил 1». Читать её надо со стороны разметки: action обещает вызвать функцию с одной FormData, а подсунутая функция ждёт два аргумента. bind как раз и превращает её в функцию одного аргумента.
Собранный путь записи выглядит так — и каждый его шаг знает только своё дело.
| Шаг | Где живёт | Что делает |
|---|---|---|
| Клик | браузер | форма собирает FormData и зовёт действие |
| Наложение | клиентский компонент | useOptimistic рисует ожидаемое |
| Разбор | действие на сервере | typeof и предикат превращают unknown в модель |
| Запись | lib/tasks.ts | createTask, setStatus |
| Обновление | revalidatePath | адрес помечен устаревшим, страница собирается заново |
| Ответ | useActionState | отказ возвращается в форму вместе с введённым |
- Действия доски лежат в отдельном файле с
"use server": их импортируют и серверная страница, и клиентский компонент. - Всё, что приезжает из формы, —
unknown:typeofотсекаетnullи файл, предикатvalue is Statusсужает строку до статуса. - Отказ проверки возвращается из действия значением, а не бросается: вместе с сообщением едет введённый текст, иначе форма после сброса опустеет.
useOptimisticне хранит данные, а накладывает ожидаемое поверх настоящего состояния — и снимает наложение, как только действие закончилось.- Если карточка мигает и пропадает, дело не в наложении: действие не пометило адрес устаревшим через
revalidatePath. - Идентификатор задачи привязывается через
bindи остаётся на сервере; из браузера едут только те поля, которые можно перепроверить.
1/4Массив статусов объявлен как const STATUSES = ["todo", "doing", "done"] as const. В переменной value лежит string. Что скажет компилятор на STATUSES.includes(value)?
2/4Действие вернуло { status: "error", title: "да", message: "…" }, а в разметке стоит <input name="title" defaultValue={state.status === "error" ? state.title : ""} />. Что окажется в поле после отправки?
3/4Действие добавило задачу в базу, наложение useOptimistic нарисовало карточку. Действие закончилось — карточка пропала, хотя после перезагрузки страницы она на месте. В чём причина?
4/4Действие объявлено как moveTask(id: string, form: FormData) и передано в разметку как <form action={moveTask}>, без bind. Что будет?
Предикат вместо as
Функция isStatus возвращает true на что угодно, и в setStatus попадает статус deleted, которого на доске нет. Перепишите её в настоящий предикат: проверка должна и отсеивать чужие значения, и сужать тип — так, чтобы утверждение raw as Status стало ненужным и его можно было убрать.
Предикат отличается от обычной проверки типом возврата: value is Status вместо boolean. Внутри — две проверки: строка ли это вообще и есть ли она в списке.
Расширение STATUSES as readonly string[] обязательно: у кортежа as const метод includes принимает только сами литералы. Это единственный безопасный вид утверждения — оно разрешает больше, а не обещает лишнего.
Наложение, которое переносит
Редьюсер наложения возвращает список без изменений, поэтому карточка переезжает в «Готово» только после ответа сервера — через 400 мс. Напишите редьюсер так, чтобы она оказывалась там сразу, а по окончании действия оставалась на месте.
Редьюсер получает текущий список и описание переноса, а возвращает новый список — тем же map, которым массив в состоянии меняют в статье «Объекты и массивы в состоянии».
Обе строки вывода теперь показывают единицу. Вторая — уже не наложение: к этому моменту действие закончилось, наложение снято, а карточка лежит в настоящем состоянии, потому что действие его обновило. Если бы не обновило — второй строкой снова стал бы ноль.
Третье действие: удалить
На доске не хватает удаления. Напишите действие deleteTask по образцу moveTask: идентификатор приходит привязкой, из формы не читается ничего, после записи адрес помечается устаревшим. Опишите заодно, каким будет редьюсер наложения для удаления.
Действие получается короче остальных: разбирать нечего — из браузера не приезжает ни одного поля.
Первым аргументом стоит id, а FormData React добавит последней — поэтому подпись (id: string, form: FormData) тоже подойдёт, если поля всё-таки понадобятся. В разметке действие привязывается так же, как перенос:
Наложение — фильтр вместо map: (current: Task[], id: string) => current.filter((task) => task.id !== id). И то же правило: объявлять его надо у списка, иначе карточка успеет размонтироваться вместе со своим состоянием.