Server Actions: форма без fetch
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Отправка формы до сих пор выглядела так: поймать submit, погасить перезагрузку, собрать FormData, отправить fetch на свой маршрут, разобрать ответ, обновить состояние. Server Action оставляет от этого списка одну обычную асинхронную функцию: вы отдаёте её форме в action — и запрос к серверу случается сам, без единого fetch.
У формы в HTML атрибут action был всегда — это адрес, куда браузер отправит поля. React 19 расширил его: туда можно положить функцию. Тогда отправку перехватывает React — сам гасит переход, сам собирает FormData из полей и передаёт её вашей функции готовой.
Сравните с тем, что вы писали в статье про неуправляемые поля: onSubmit, event.preventDefault(), new FormData(event.currentTarget). Три строки обвязки, одинаковые в каждой форме проекта, исчезают.
Ни preventDefault, ни new FormData в примере нет, а страница не перезагрузилась и значение поля дошло до функции. Последняя строка вывода — самая любопытная: поле опустело само. Это делает React, и только для неуправляемых полей: значение управляемого поля живёт в состоянии, и очищать его по-прежнему вам.
Сброс формы происходит после того, как действие закончило работу, — поэтому в примере значение читается с задержкой, а не сразу после нажатия.
Тип пропса рассказывает всю историю сам. Попробуйте по привычке принять событие — и компилятор объяснит, чего он ждёт.
В диагностике целиком выписан тип action: string — как в обычном HTML, — либо функция (formData: FormData) => void | Promise<void>. А после диагностики пример всё равно выполнился, и в выводе видно, чем кончается старая привычка: React позвал функцию с FormData, у которой нет никакого preventDefault.
Возвращать значение из действия формы тип не разрешает: нужен void. Результат работы действия забирают через useActionState — это следующая статья главы.
Пока всё написанное было браузерным React: форма и функция живут на одной странице, никакой сервер в этом не участвует. Превращает функцию в серверное действие директива "use server".
Её легко прочитать неправильно. Директива не переносит код на сервер: в серверном компоненте код и так на сервере, ему некуда переезжать. Она говорит обратное — эту функцию разрешено позвать снаружи, из браузера. Это зеркало "use client": та объявляет вход в клиентскую часть, эта — вход с клиента на сервер.
| Вопрос | "use client" | "use server" |
|---|---|---|
| Что помечает | файл — целиком | функцию — по одной |
| Где стоит | в прологе файла | в прологе файла или первой строкой в теле функции |
| Что означает | этот код поедет в браузер | эту функцию можно вызвать из браузера |
| Что уезжает к читателю | весь файл и всё, что он импортирует | только идентификатор функции |
| Кто проверяет | сборщик Next | сборщик Next |
Мест для директивы два. Первое — отдельный файл: строка в прологе делает действием каждый его экспорт, и такой файл потом импортируют откуда угодно, в том числе из клиентских компонентов.
Второе — тело самой функции внутри серверного компонента. Тогда действие лежит рядом с формой и видит её окружение: пропсы страницы, значения из params, всё, что вычислено выше.
Оба примера — листинги, в песочнице они не запускаются: серверной стороны у неё нет, а для компилятора TypeScript «use server» — просто строка. Директивы разбирает сборщик Next, и console.log из действия печатается в терминале next dev — этот вывод и записан под кодом.
Правил всего три, и все три проверяет сборка, а не компилятор. Действие обязано быть асинхронным — на синхронной функции сборка падает со словами Server Actions must be async functions и показывает на строку с директивой. В файле с директивой в прологе действиями становятся только экспортированные функции. И сама директива должна стоять в прологе — выше неё допустимы комментарии, но не инструкции; про это была отдельная история в статье о границе "use client".
Потому что вызов превращается в сетевой запрос, а у запроса ответ бывает только обещанием. Сделать вид, что функция синхронная, невозможно ни на одной стороне: клиент получит промис в любом случае, а требование писать async просто не даёт написать сигнатуру, которую нельзя выполнить.
Отсюда же следствие для типов: тип действия — это тип обычной асинхронной функции, и он один на обе стороны границы. Клиент вызывает ровно то, что объявил сервер, и никакого отдельного описания запроса и ответа между ними нет.
В коде нет ни fetch, ни маршрута, ни адреса — а запрос уходит. Разберём, что именно летит по проводу.
В браузер уезжает не тело действия: оно осталось на сервере вместе со всем, что импортирует. Сборка оставляет от функции ссылку — строку-идентификатор, которую считает сама. React кладёт эту строку в форму, и при отправке уходит POST на адрес текущей страницы: идентификатор действия — в заголовке next-action, поля формы — в теле запроса. Сервер находит функцию по идентификатору, выполняет её и отвечает не JSON'ом, а обновлённой разметкой страницы.
Модель огрубляет ровно одно: реестр держит не ваш Map, а сборка Next, и идентификатор там длиннее. Но суть та же — на клиенте от действия остаётся строка, а вызов по этой строке находит функцию на сервере.
| Шаг | Своими руками через fetch | Через действие |
|---|---|---|
| Адрес | нужен маршрут: app/api/tasks/route.ts | не нужен: POST уходит на адрес текущей страницы |
| Отправка | onSubmit, preventDefault, new FormData, fetch | action={addTask} |
| Упаковка данных | JSON.stringify на клиенте, разбор на сервере | делает React: аргументы и результат едут сами |
| Типы | по обе стороны свои, связь держится на договорённости | одна функция — одна сигнатура на обе стороны |
| Без JavaScript | не работает | работает: форму отправляет браузер |
Обновятся ли данные на экране после мутации — отдельный вопрос: сервер должен пересчитать страницу. Про revalidatePath и revalidateTag — четвёртая статья этой главы.
У действия формы аргумент один — FormData. Но почти всегда нужен ещё и контекст: какую именно задачу удаляем, в каком проекте, от чьего имени. Прямолинейный способ — скрытое поле: оно попадёт в FormData вместе с остальными. Способ аккуратнее — привязать аргумент заранее.
Порядок в выводе стоит запомнить: привязанные аргументы идут первыми, FormData — всегда последней. Никакого особого механизма React здесь не изобретал, это обычный Function.prototype.bind из части I.
Разницы в надёжности между способами нет. И скрытое поле, и привязанный аргумент уезжают в браузер и возвращаются оттуда: в разметке рядом со скрытым полем лежит служебное поле с привязанными значениями. Вернуться они могут изменёнными, поэтому права действие проверяет само, на сервере; этому посвящена пятая статья главы.
Вторая граница — сериализация. Аргументы и возвращаемое значение пересекают сеть, поэтому годится не всё.
| Переживает границу | Не переживает |
|---|---|
строки, числа, boolean, null, undefined | функции — кроме самих действий |
| массивы и простые объекты из таких же значений | классы и их экземпляры |
Date, Map, Set, BigInt | узлы DOM, события, Symbol |
FormData, File, Blob | разметка: элементы JSX и компоненты |
Файл из поля <input type="file"> приезжает в действие целиком: FormData умеет возить File, а значит, загрузка файла — это тоже просто действие.
Между тем, как HTML приехал в браузер, и тем, как React в нём ожил, проходит время — на медленной сети заметное. Форма с onSubmit в этом окне мертва: обработчика ещё нет, нажатие перезагружает страницу и теряет введённое. Форма с серверным действием работает, и разметка объясняет почему.
| Что Next пишет в форму | Зачем |
|---|---|
method="POST" | такую форму браузер умеет отправить сам, без всякого React |
action="" | пустой адрес — это адрес текущей страницы |
encType="multipart/form-data" | чтобы вместе с полями уехали файлы |
скрытое поле $ACTION_ID_… | тот самый идентификатор: по нему сервер найдёт функцию |
Отправка такой формы без единой строчки JavaScript — обычный POST на текущий адрес, где идентификатор лежит не в заголовке, а в теле, рядом с полями. Сервер выполняет действие и отвечает целой страницей, уже с новыми данными. Когда React оживёт, он перехватит отправку и сделает ровно то же самое, только без перезагрузки.
Проверить это утверждение можно с обратной стороны: посмотреть, что React пишет в атрибут action, когда действие не серверное, а обычная функция в браузере.
В атрибут уехала заглушка: javascript:-адрес, который бросает ошибку с объяснением, если форму отправят мимо React. Иначе и быть не может — клиентскую функцию отправить некуда, сервер про неё не знает. Отсюда вывод, который стоит запомнить: прогрессивное улучшение даёт не форма с функцией, а форма с серверной функцией.
Ещё одна мелочь, которая приезжает вместе с настоящей формой: кнопка со своим действием. Атрибут formAction в HTML перекрывает адрес формы — в React он точно так же перекрывает функцию.
И последнее. Действие — обычная асинхронная функция, и позвать её можно не только формой: из клиентского компонента она вызывается прямо в обработчике нажатия. Но тогда всё, что форма делала бесплатно, придётся делать руками — и работать до гидратации такая кнопка не будет.
Всё, что здесь запускается, живёт в браузере: сервера у песочницы нет, и настоящий сетевой вызов действия показать невозможно. Проверяемо в примерах другое — как React обходится с формой: какой аргумент приходит функции, в каком порядке идут привязанные значения, что стоит в атрибуте action, когда форма сбрасывается.
В настоящем проекте разница видна сразу и глазами: console.log из действия печатается не в консоли браузера, а в терминале, где запущен next dev. Это первый и самый надёжный признак, что функция действительно выполнилась на сервере.
- В
<form action={fn}>React гасит переход сам и передаёт функции готовуюFormData— ниpreventDefault, ниnew FormData(form)писать не нужно. "use server"не переносит код на сервер: она открывает уже серверную функцию для вызова из браузера — в прологе файла или первой строкой в теле функции.- Действие обязано быть асинхронным: вызов превращается в сетевой запрос, а у запроса ответ бывает только обещанием.
- В браузер уезжает не тело действия, а его идентификатор; отправка формы — это
POSTна адрес текущей страницы, без отдельного маршрута и безfetch. - Дополнительные аргументы привязываются через
bindи идут передFormData; всё, что пересекает границу, должно быть сериализуемым и проверяться на сервере. - Форма с серверным действием работает до гидратации: в разметке это обычная форма с методом
POST, а не заглушка наjavascript:.
1/4Функция передана форме в action. Что придёт ей аргументом при отправке?
2/4В первой строке файла app/tasks/actions.ts стоит "use server". Что она означает?
3/4Действие объявлено как removeTask(taskId: string, formData: FormData), а форме передан removeTask.bind(null, "task-7"). Что окажется во втором аргументе при отправке?
4/4Что от серверного действия попадает в браузер?
Переписать форму на действие
Форма написана привычным способом: значение поля в состоянии, отправка перехвачена руками, очистка вручную. Перепишите её на действие: поле должно стать неуправляемым, а от обработчика остаться только функция, принимающая FormData.
Состояние поля здесь не нужно ни для чего: значение читается один раз при отправке, а поле после неё очищается. Ровно это и делает форма с действием — значение берётся из FormData, а сброс выполняет React.
Из компонента ушли useState, onChange, onSubmit, preventDefault и setTitle("") — пять строк обвязки на одно поле. Атрибут name при этом стал обязательным: без него поле не попадёт в FormData.
Управляемым поле остаётся, когда состояние действительно нужно кому-то ещё: подсказки при вводе, проверка на лету, кнопка, которая гаснет на пустом значении. Для «прочитать при отправке» состояние — лишняя работа на каждое нажатие клавиши.
Убрать скрытое поле
Идентификатор задачи едет в действие скрытым полем. Перепишите строку так, чтобы id приходил отдельным аргументом, а скрытого поля в разметке не осталось. Сигнатура действия должна стать removeTask(taskId: string, formData: FormData).
Привязываем первый аргумент через bind, а FormData React допишет следом сам.
Разметка стала честнее: в форме остались только те поля, которые заполняет человек. Но безопаснее она не стала — привязанное значение точно так же едет через браузер, и действие обязано проверить права на задачу само.
Две кнопки, два действия
В форме две кнопки, но обе отправляют её в save. Сделайте так, чтобы «Удалить» вызывала remove, не заводя вторую форму и не добавляя onClick.
Кнопка умеет перекрывать действие формы — атрибутом formAction, ровно как в обычном HTML он перекрывает адрес.
Обработчик onClick здесь был бы хуже по двум причинам. Во-первых, он не отменяет отправку формы — пришлось бы гасить её вручную. Во-вторых, без формы отправка перестала бы работать до гидратации, а с formAction браузер справляется сам: он и в чистом HTML отправляет форму по адресу нажатой кнопки.