Добавление и изменение через действия

проект≈ 14 минут

Здесь будет встроен видео-разбор темы (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" в прологе файла делает вызываемым из браузера каждый его экспорт. Вот каркас обоих действий: тела хранилища здесь только объявлены, потому что песочница выполняет один файл, а не проект.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три вещи, которые задают весь остаток статьи. Первая: отказ проверки — это возвращённое значение, а не исключение; брошенная ошибка доедет до error.tsx, а в проде превратится в общую фразу. Вторая: второе действие не возвращает ничего — форме-кнопке результат не нужен, ей достаточно, что страница пересоберётся. Третья: id у moveTask стоит первым аргументом, и придёт он не из полей, а из bind.

Действие — это открытый эндпоинт. Разметка вокруг кнопки ничего не решает: проверки живут внутри действия.

Директивы "use client" и "use server" в одном файле не уживаются: первая говорит «этот модуль уезжает в браузер», вторая — «эти функции остаются на сервере». Поэтому клиентская часть доски действия только импортирует.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй способ — объявить действие прямо в серверном компоненте, с "use server" первой строкой в теле функции. Для доски он неудобен: одно и то же действие нужно и списку, и странице задачи.

Статус задачи на доске — не строка, а один из трёх литералов. Из формы же приезжает FormDataEntryValue | null: строка, файл или ничего. Прогноз перед запуском: на что здесь пожалуется компилятор — и что при этом напечатает пример?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отметок будет три, на двух строках. Первая ожидаема: String(raw) даёт string, а setStatus просит одно из трёх слов. Две другие стоят на одной строке — и обе интереснее. Ругается сам includes: у кортежа, замороженного через as const, тип элемента — то же объединение трёх литералов, и метод требует аргумент этого же типа. Проверка, которая должна была доказать, что строка годится, отказывается принимать недоказанную строку.

Третья отметка — на setStatus внутри if, и она объясняет, почему одной проверкой дело не кончается: includes возвращает boolean, а не сужение, и в теле условия значение осталось всё той же string. Сужать умеет предикат из статьи «Свои проверки типа: предикаты is и assert» — функция, которая отвечает не «да/нет», а «да, и это Status».

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обратите внимание на as внутри предиката: он расширяет кортеж до readonly string[], а не сужает что-то до нужного типа. Такое утверждение ничего не прячет — оно лишь разрешает includes принять произвольную строку, и после него проверка остаётся настоящей. Утверждения вида value as Status, наоборот, гасят ровно ту диагностику, ради которой всё и затевалось.

parseTitle устроен так же: сначала typeof отсекает и null (поля нет), и File (поле подменили на type="file"), и только потом строка обрезается и меряется. Наружу уходит размеченное объединение — тот же приём, что и в разборе входных данных из части III.

Правило доски: в базу не попадает ничего, что не прошло через parseTitle или isStatus.

STATUSES нужен не только проверке. Из него же берутся колонки и их порядок, а подписи держит Record<Status, string> — забыть колонку компилятор не даст.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Прогноз перед запуском: что останется в поле после отказа и что — после успешного добавления?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После отказа в поле стоит «да» — без пробелов, которые мы туда напечатали. Значит, текст не «остался», а приехал: форма сброшена, но defaultValue к моменту сброса уже другой — действие вернуло обрезанное название в состоянии, и разметка подставила его обратно. После успеха действие возвращает { status: "ok" } без текста, defaultValue становится пустым — и поле очищается само, без reset().

Ошибка здесь — обычное значение, поэтому её видно и в разметке, и в типах: state.message существует только в варианте error, и обратиться к нему мимо проверки компилятор не даст. Это тот же разбор вариантов, что был в useReducer, только редьюсер асинхронный и живёт на сервере.

required и minLength из части II остаются: они ловят опечатку до сети и не дают отправить пустую форму. Но действие — открытый адрес, и запрос к нему приходит и мимо браузера, поэтому те же границы повторяются в parseTitle.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Прогноз: сколько карточек в списке через 200 мс после клика и сколько — через 700 мс?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Наложение — не второе хранилище: shown вычисляется из tasks и живёт ровно столько, сколько идёт действие. Поэтому у него есть неприятная изнанка, на которой в проекте спотыкаются все.

Следующий пример — та же доска, но действие честно пишет в базу и не зовёт revalidatePath. Прогноз: что покажет список после того, как действие закончится, и что при этом будет лежать в базе?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Карточка мигнула и пропала, хотя запись прошла. Наложение отпустилось — а источник, из которого рисуется список, остался прежним. В Next.js этот источник обновляет не setState, а revalidatePath внутри действия: он помечает адрес устаревшим, страница собирается заново, и к моменту, когда наложение исчезает, под ним уже лежат новые данные. Без этой строки useOptimistic выглядит как сломанный, хотя сломано соседнее.

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

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вторая операция устроена иначе: полей для ввода нет, есть кнопка внутри карточки. Форма всё равно нужна — она собирает FormData и зовёт действие, — но состоит из скрытого поля и кнопки. Задача, которую переносим, приходит не полем, а привязкой: move.bind(null, task.id).

Прогноз: что напечатают строки про «Готово» через 200 мс и через 700 мс после клика — и что покажет строка рендера про сервер и экран?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две последние строки рендера одинаковы, и это хорошая новость: сначала наложение рисует карточку поверх уже обновлённого списка, потом снимается — и на экране ничего не меняется, потому что настоящие данные совпали с нарисованными. Именно так выглядит удачное предсказание: перерисовки не видно.

Наложение объявлено у списка, а не в карточке, и это принципиально. Во-первых, перенос — событие сразу двух колонок: карточка не знает, куда её переставляют. Во-вторых, при переезде карточка размонтируется, а вместе с ней исчезло бы и её собственное состояние — включая наложение, которое как раз и должно пережить этот момент.

Скрытое поле здесь несёт статус, а не идентификатор задачи. Разница не косметическая: поле легко переписать в инспекторе, поэтому статус проходит через isStatus, а id вообще не едет из браузера — он замкнут в привязке на сервере. Что бывает, когда про bind забывают, компилятор показывает сам.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика заканчивается неожиданно вывернутой фразой: «Целевая подпись предоставляет слишком мало аргументов. Ожидал 2 или больше, но получил 1». Читать её надо со стороны разметки: action обещает вызвать функцию с одной FormData, а подсунутая функция ждёт два аргумента. bind как раз и превращает её в функцию одного аргумента.

Собранный путь записи выглядит так — и каждый его шаг знает только своё дело.

ШагГде живётЧто делает
Кликбраузерформа собирает FormData и зовёт действие
Наложениеклиентский компонентuseOptimistic рисует ожидаемое
Разбордействие на сервереtypeof и предикат превращают unknown в модель
Записьlib/tasks.tscreateTask, 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 стало ненужным и его можно было убрать.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Предикат отличается от обычной проверки типом возврата: value is Status вместо boolean. Внутри — две проверки: строка ли это вообще и есть ли она в списке.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Расширение STATUSES as readonly string[] обязательно: у кортежа as const метод includes принимает только сами литералы. Это единственный безопасный вид утверждения — оно разрешает больше, а не обещает лишнего.

Наложение, которое переносит

Редьюсер наложения возвращает список без изменений, поэтому карточка переезжает в «Готово» только после ответа сервера — через 400 мс. Напишите редьюсер так, чтобы она оказывалась там сразу, а по окончании действия оставалась на месте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Редьюсер получает текущий список и описание переноса, а возвращает новый список — тем же map, которым массив в состоянии меняют в статье «Объекты и массивы в состоянии».

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обе строки вывода теперь показывают единицу. Вторая — уже не наложение: к этому моменту действие закончилось, наложение снято, а карточка лежит в настоящем состоянии, потому что действие его обновило. Если бы не обновило — второй строкой снова стал бы ноль.

Третье действие: удалить

На доске не хватает удаления. Напишите действие deleteTask по образцу moveTask: идентификатор приходит привязкой, из формы не читается ничего, после записи адрес помечается устаревшим. Опишите заодно, каким будет редьюсер наложения для удаления.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Действие получается короче остальных: разбирать нечего — из браузера не приезжает ни одного поля.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первым аргументом стоит id, а FormData React добавит последней — поэтому подпись (id: string, form: FormData) тоже подойдёт, если поля всё-таки понадобятся. В разметке действие привязывается так же, как перенос:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Наложение — фильтр вместо map: (current: Task[], id: string) => current.filter((task) => task.id !== id). И то же правило: объявлять его надо у списка, иначе карточка успеет размонтироваться вместе со своим состоянием.