Изменение данных
В предыдущей главе вы реализовали поиск и пагинацию с использованием URL Search Params и API Next.js. Давайте продолжим работу над страницей Invoices, добавив возможность создавать, обновлять и удалять счета!
Что такое Server Actions?
Server Actions в React позволяют запускать асинхронный код напрямую на сервере. Они устраняют необходимость создавать API-эндпоинты для изменения данных. Вместо этого вы пишете асинхронные функции, которые выполняются на сервере и могут быть вызваны из клиентских или серверных компонентов.
Безопасность — главный приоритет для веб-приложений, так как они могут быть уязвимы к различным угрозам. Именно здесь пригодятся Server Actions. Они включают такие функции, как зашифрованные замыкания, строгая проверка ввода, хеширование сообщений об ошибках, ограничения хостов и многое другое — всё это работает вместе, чтобы значительно повысить безопасность вашего приложения.
Использование форм с Server Actions
В React вы можете использовать атрибут action
в элементе <form>
для вызова действий. Действие автоматически получит нативный объект FormData, содержащий захваченные данные.
Например:
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Логика изменения данных...
}
// Вызов действия с помощью атрибута "action"
return <form action={create}>...</form>;
}
Преимущество вызова Server Action внутри серверного компонента — прогрессивное улучшение: формы работают даже если JavaScript ещё не загрузился на клиенте. Например, при медленном интернет-соединении.
Next.js с Server Actions
Server Actions также глубоко интегрированы с кешированием в Next.js. Когда форма отправляется через Server Action, вы можете не только изменять данные, но и перевалидировать связанный кеш с помощью API, таких как revalidatePath
и revalidateTag
.
Давайте посмотрим, как это всё работает вместе!
Создание счета
Вот шаги, которые вы выполните для создания нового счета:
- Создайте форму для захвата ввода пользователя.
- Создайте Server Action и вызовите её из формы.
- Внутри Server Action извлеките данные из объекта
formData
. - Проверьте и подготовьте данные для вставки в базу данных.
- Вставьте данные и обработайте возможные ошибки.
- Перевалидируйте кеш и перенаправьте пользователя обратно на страницу счетов.
1. Создание нового маршрута и формы
Для начала внутри папки /invoices
добавьте новый сегмент маршрута /create
с файлом page.tsx
:

Вы будете использовать этот маршрут для создания новых счетов. Внутри файла page.tsx
вставьте следующий код, затем изучите его:
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
const customers = await fetchCustomers();
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Счета', href: '/dashboard/invoices' },
{
label: 'Создать счет',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
);
}
Ваша страница — это серверный компонент, который получает customers
и передаёт их в компонент <Form>
. Чтобы сэкономить время, мы уже создали компонент <Form>
за вас.
Перейдите к компоненту <Form>
, и вы увидите, что форма:
- Имеет один элемент
<select>
(выпадающий список) со списком клиентов. - Имеет один элемент
<input>
для суммы сtype="number"
. - Имеет два элемента
<input>
для статуса сtype="radio"
. - Имеет одну кнопку с
type="submit"
.
На http://localhost:3000/dashboard/invoices/create вы должны увидеть следующий интерфейс:

2. Создание Server Action
Отлично, теперь давайте создадим Server Action, которая будет вызываться при отправке формы.
Перейдите в папку lib/
и создайте новый файл с именем actions.ts
. В начале этого файла добавьте директиву React use server
:
'use server';
Добавляя 'use server'
, вы помечаете все экспортируемые функции в файле как Server Actions. Эти серверные функции затем можно импортировать и использовать в клиентских и серверных компонентах. Любые функции в этом файле, которые не используются, будут автоматически удалены из финального бандла приложения.
Вы также можете писать Server Actions напрямую внутри серверных компонентов, добавляя "use server"
внутри действия. Но для этого курса мы будем хранить их все в отдельном файле. Мы рекомендуем иметь отдельный файл для ваших действий.
В вашем файле actions.ts
создайте новую асинхронную функцию, которая принимает formData
:
'use server';
export async function createInvoice(formData: FormData) {}
Затем в вашем компоненте <Form>
импортируйте createInvoice
из файла actions.ts
. Добавьте атрибут action
к элементу <form>
и вызовите действие createInvoice
.
import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
export default function Form({
customers,
}: {
customers: CustomerField[];
}) {
return (
<form action={createInvoice}>
// ...
)
}
Полезно знать: В HTML вы бы передавали URL в атрибут
action
. Этот URL был бы местом, куда должны быть отправлены данные формы (обычно API-эндпоинт).Однако в React атрибут
action
считается специальным пропсом — это означает, что React расширяет его, позволяя вызывать действия.Под капотом Server Actions создают
POST
API-эндпоинт. Вот почему вам не нужно вручную создавать API-эндпоинты при использовании Server Actions.
3. Извлечение данных из formData
Вернувшись в файл actions.ts
, вам нужно извлечь значения из formData
. Для этого есть несколько методов. Для этого примера давайте используем метод .get(name)
.
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Проверка:
console.log(rawFormData);
}
Совет: Если вы работаете с формами, у которых много полей, вы можете рассмотреть использование метода
entries()
сObject.fromEntries()
в JavaScript.
Чтобы проверить, что всё подключено правильно, попробуйте отправить форму. После отправки вы должны увидеть данные, которые вы только что ввели в форму, в вашем терминале (не в браузере).
Теперь, когда ваши данные имеют форму объекта, с ними будет гораздо проще работать.
4. Проверка и подготовка данных
Прежде чем отправлять данные формы в вашу базу данных, вы хотите убедиться, что они в правильном формате и с правильными типами. Если вы помните из начала курса, ваша таблица счетов ожидает данные в следующем формате:
export type Invoice = {
id: string; // Будет создано в базе данных
customer_id: string;
amount: number; // Хранится в центах
status: 'pending' | 'paid';
date: string;
};
Пока у вас есть только customer_id
, amount
и status
из формы.
Проверка типов и приведение
Важно проверить, что данные из вашей формы соответствуют ожидаемым типам в вашей базе данных. Например, если вы добавите console.log
внутри вашего действия:
console.log(typeof rawFormData.amount);
Вы заметите, что amount
имеет тип string
, а не number
. Это потому, что элементы input
с type="number"
на самом деле возвращают строку, а не число!
Для проверки типов у вас есть несколько вариантов. Хотя вы можете проверять типы вручную, использование библиотеки для проверки типов может сэкономить вам время и усилия. Для вашего примера мы будем использовать Zod, библиотеку валидации, ориентированную на TypeScript, которая может упростить эту задачу.
В вашем файле actions.ts
импортируйте Zod и определите схему, которая соответствует форме вашего объекта. Эта схема будет проверять formData
перед сохранением в базу данных.
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
Поле amount
специально настроено на приведение (изменение) из строки в число с одновременной проверкой его типа.
Затем вы можете передать ваш rawFormData
в CreateInvoice
для проверки типов:
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
Хранение значений в центах
Обычно хорошей практикой является хранение денежных значений в центах в вашей базе данных, чтобы избежать ошибок с плавающей точкой в JavaScript и обеспечить большую точность.
Давайте преобразуем сумму в центы:
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
}
Создание новых дат
Наконец, давайте создадим новую дату в формате "YYYY-MM-DD" для даты создания счета:
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
}
5. Вставка данных в базу данных
Теперь, когда у вас есть все значения, необходимые для вашей базы данных, вы можете создать SQL-запрос для вставки нового счета в вашу базу данных и передать переменные:
import { z } from 'zod';
import postgres from 'postgres';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
Сейчас мы не обрабатываем никакие ошибки. Мы поговорим об этом в следующей главе. А пока давайте перейдём к следующему шагу.
6. Ревалидация и редирект
Next.js имеет клиентский кеш маршрутизатора, который временно хранит сегменты маршрутов в браузере пользователя. Вместе с префетчингом этот кеш обеспечивает быструю навигацию между маршрутами, уменьшая количество запросов к серверу.
Поскольку вы обновляете данные, отображаемые на маршруте счетов, вам нужно очистить этот кеш и инициировать новый запрос к серверу. Это можно сделать с помощью функции revalidatePath
из Next.js:
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import postgres from 'postgres';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
}
После обновления базы данных путь /dashboard/invoices
будет ревалидирован, и свежие данные будут загружены с сервера.
На этом этапе также нужно перенаправить пользователя обратно на страницу /dashboard/invoices
. Это можно сделать с помощью функции redirect
из Next.js:
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import postgres from 'postgres';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// ...
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Поздравляем! Вы только что реализовали свое первое серверное действие (Server Action). Протестируйте его, добавив новый счет. Если все работает правильно:
- После отправки формы вы должны быть перенаправлены на маршрут
/dashboard/invoices
. - Новый счет должен появиться в верхней части таблицы.
Обновление счета
Форма обновления счета похожа на форму создания, но вам нужно передать id
счета для обновления записи в базе данных. Давайте посмотрим, как получить и передать id
счета.
Шаги для обновления счета:
- Создайте новый динамический сегмент маршрута с
id
счета. - Получите
id
счета из параметров страницы. - Загрузите конкретный счет из базы данных.
- Заполните форму данными счета.
- Обновите данные счета в базе данных.
1. Создание динамического сегмента маршрута с id
счета
Next.js позволяет создавать Динамические сегменты маршрутов, когда точное название сегмента неизвестно и нужно создавать маршруты на основе данных. Это могут быть заголовки блогов, страницы продуктов и т.д. Динамические сегменты создаются путем заключения имени папки в квадратные скобки, например, [id]
, [post]
или [slug]
.
В папке /invoices
создайте новый динамический маршрут [id]
, а затем новый маршрут edit
с файлом page.tsx
. Структура файлов должна выглядеть так:
![Папка Invoices с вложенной папкой [id] и папкой edit внутри нее](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/learn/light/edit-invoice-route.png)
В компоненте <Table>
обратите внимание на кнопку <UpdateInvoice />
, которая получает id
счета из записей таблицы.
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
return (
// ...
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</td>
// ...
);
}
Перейдите к компоненту <UpdateInvoice />
и обновите атрибут href
компонента Link
, чтобы он принимал пропс id
. Можно использовать шаблонные литералы для ссылки на динамический сегмент маршрута:
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
// ...
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
2. Получение id
счета из параметров страницы
Вернитесь к компоненту <Page>
и вставьте следующий код:
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Счета', href: '/dashboard/invoices' },
{
label: 'Редактировать счет',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
Обратите внимание, что он похож на страницу создания счета, но импортирует другую форму (из файла edit-form.tsx
). Эта форма должна быть предварительно заполнена значениями defaultValue
для имени клиента, суммы счета и статуса. Чтобы предварительно заполнить поля формы, нужно загрузить конкретный счет с помощью id
.
Помимо searchParams
, компоненты страниц также принимают пропс params
, который можно использовать для доступа к id
. Обновите компонент <Page>
, чтобы он принимал этот пропс:
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
// ...
}
3. Загрузка конкретного счета
Затем:
- Импортируйте новую функцию
fetchInvoiceById
и передайте ейid
. - Импортируйте
fetchCustomers
для загрузки имен клиентов для выпадающего списка.
Можно использовать Promise.all
для параллельной загрузки счета и клиентов:
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
Вы увидите временную ошибку TypeScript для пропса invoice
в терминале, потому что invoice
может быть undefined
. Пока не беспокойтесь об этом — вы исправите это в следующей главе при добавлении обработки ошибок.
Отлично! Теперь протестируйте, что все правильно подключено. Перейдите по адресу http://localhost:3000/dashboard/invoices и нажмите на иконку карандаша для редактирования счета. После перехода вы должны увидеть форму, предварительно заполненную данными счета:

URL также должен обновиться с id
, например: http://localhost:3000/dashboard/invoice/uuid/edit
UUID vs. Автоинкрементные ключи
Мы используем UUID вместо инкрементных ключей (например, 1, 2, 3 и т.д.). Это делает URL длиннее, но UUID исключают риск коллизии идентификаторов, являются глобально уникальными и снижают риск атак перечисления — что делает их идеальными для больших баз данных.
Однако, если вы предпочитаете более чистые URL, можно использовать автоинкрементные ключи.
4. Передача id
в серверное действие
Наконец, нужно передать id
в серверное действие, чтобы обновить правильную запись в базе данных. Нельзя передать id
как аргумент следующим образом:
// Передача id как аргумента не сработает
<form action={updateInvoice(id)}>
Вместо этого можно передать id
в серверное действие с помощью JS bind
. Это гарантирует, что любые значения, переданные в серверное действие, будут закодированы.
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return <form action={updateInvoiceWithId}>{/* ... */}</form>;
}
Примечание: Использование скрытого поля ввода в форме также работает (например,
<input type="hidden" name="id" value={invoice.id} />
). Однако значения будут видны в исходном HTML-коде, что не идеально для конфиденциальных данных.
Затем в файле actions.ts
создайте новое действие updateInvoice
:
// Используйте Zod для обновления ожидаемых типов
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Аналогично действию createInvoice
, здесь вы:
- Извлекаете данные из
formData
. - Проверяете типы с помощью Zod.
- Конвертируете сумму в центы.
- Передаете переменные в SQL-запрос.
- Вызываете
revalidatePath
для очистки клиентского кеша и нового запроса к серверу. - Вызываете
redirect
для перенаправления пользователя на страницу счетов.
Протестируйте, отредактировав счет. После отправки формы вы должны быть перенаправлены на страницу счетов, и счет должен быть обновлен.
Удаление счета
Чтобы удалить счет с помощью серверного действия, оберните кнопку удаления в элемент <form>
и передайте id
в серверное действие с помощью bind
:
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Удалить</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
В файле actions.ts
создайте новое действие deleteInvoice
.
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
Поскольку это действие вызывается на маршруте /dashboard/invoices
, не нужно вызывать redirect
. Вызов revalidatePath
инициирует новый запрос к серверу и перерисовку таблицы.
Дополнительные материалы
В этой главе вы узнали, как использовать серверные действия для изменения данных. Также вы узнали, как использовать API revalidatePath
для ревалидации кеша Next.js и redirect
для перенаправления пользователя на новую страницу.
Для дополнительного изучения можно прочитать о безопасности серверных действий.