Пошук уроків, статей та іншого контенту
Реалізуєте перевірку типів і значень вхідних даних до виконання мутації.
Server Action отримує дані від клієнта, але клієнту не можна довіряти. Користувач може:
змінити HTML-форму в браузері;
надіслати запит вручну;
передати відсутнє поле;
передати значення неправильного типу;
обійти HTML-атрибути required, min, max та pattern.
TypeScript не захищає Server Action під час виконання. Він перевіряє код під час компіляції, але не перевіряє фактичні дані, які прийшли від браузера.
Окрім цього, значення з FormData не мають потрібних типів:
текстове поле повертає string;
числове поле також повертає string;
прапорець може бути відсутнім;
відсутнє поле повертає null;
поле для файлу може повернути File.
Тому дані потрібно перевірити до виконання мутації.
Для опису правил валідації зручно використовувати бібліотеку Zod:
npm install zodZod дозволяє:
описати допустиму структуру даних;
перевірити типи під час виконання;
перевірити значення;
отримати тип TypeScript на основі схеми;
безпечно обробити помилки через safeParse.
Наприклад, для завдання можна встановити такі правила:
назва обов'язкова;
назва має містити від 3 до 80 символів;
пріоритет може бути лише low, medium або high;
оцінка часу має бути цілим числом від 1 до 480 хвилин.
Створимо файл app/actions/tasks.ts:
"use server";
import { z } from "zod";
const taskSchema = z.object({
title: z
.string()
.trim()
.min(3, "Назва має містити щонайменше 3 символи")
.max(80, "Назва має містити не більше 80 символів"),
priority: z.enum(["low", "medium", "high"], {
errorMap: () => ({
message: "Оберіть коректний пріоритет",
}),
}),
estimateMinutes: z.preprocess(
(value) => {
if (typeof value !== "string" || value.trim() === "") {
return value;
}
return Number(value);
},
z
.number({
invalid_type_error: "Вкажіть оцінку часу",
})
.int("Оцінка часу має бути цілим числом")
.min(1, "Мінімальна оцінка — 1 хвилина")
.max(480, "Максимальна оцінка — 480 хвилин"),
),
});
export type CreateTaskState = {
ok: boolean;
message?: string;
errors?: Record<string, string[]>;
};
export async function createTask(
previousState: CreateTaskState,
formData: FormData,
): Promise<CreateTaskState> {
// Попередній стан потрібен API useActionState, але в цій дії він не використовується
void previousState;
const rawData = {
title: formData.get("title"),
priority: formData.get("priority"),
estimateMinutes: formData.get("estimateMinutes"),
};
const result = taskSchema.safeParse(rawData);
if (!result.success) {
const errors = result.error.issues.reduce<Record<string, string[]>>(
(accumulator, issue) => {
const field = issue.path[0];
if (typeof field === "string") {
accumulator[field] ??= [];
accumulator[field].push(issue.message);
}
return accumulator;
},
{},
);
return {
ok: false,
message: "Перевірте введені дані",
errors,
};
}
const task = result.data;
// Тут має виконуватися мутація, наприклад запис до бази даних.
// На цьому прикладі виводимо вже перевірені дані в консоль.
console.log("Створено завдання:", task);
return {
ok: true,
message: "Завдання успішно створено",
};
}Важливі деталі:
formData.get() викликається до валідації.
Об'єкт rawData може містити string, File або null.
safeParse() не створює виняток, а повертає об'єкт із результатом.
Якщо дані неправильні, Server Action завершується до мутації.
result.data містить лише перевірені та перетворені дані.
Після успішної перевірки estimateMinutes має тип number, хоча з форми він прийшов як рядок.
Щоб показати результат Server Action користувачу, можна використати useActionState.
Створимо клієнтський компонент app/tasks/create-task-form.tsx:
"use client";
import { useActionState } from "react";
import {
createTask,
type CreateTaskState,
} from "@/app/actions/tasks";
const initialState: CreateTaskState = {
ok: false,
};
export function CreateTaskForm() {
const [state, formAction, isPending] = useActionState(
createTask,
initialState,
);
return (
<form action={formAction}>
<div>
<label htmlFor="title">Назва</label>
<input id="title" name="title" type="text" />
{state.errors?.title?.[0] && (
<p>{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="priority">Пріоритет</label>
<select id="priority" name="priority" defaultValue="">
<option value="" disabled>
Оберіть пріоритет
</option>
<option value="low">Низький</option>
<option value="medium">Середній</option>
<option value="high">Високий</option>
</select>
{state.errors?.priority?.[0] && (
<p>{state.errors.priority[0]}</p>
)}
</div>
<div>
<label htmlFor="estimateMinutes">
Оцінка часу в хвилинах
</label>
<input
id="estimateMinutes"
name="estimateMinutes"
type="number"
/>
{state.errors?.estimateMinutes?.[0] && (
<p>{state.errors.estimateMinutes[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Збереження..." : "Створити завдання"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}Компонент можна використати на сторінці:
import { CreateTaskForm } from "./create-task-form";
export default function TasksPage() {
return (
<main>
<h1>Нове завдання</h1>
<CreateTaskForm />
</main>
);
}Після відправлення форми Next.js викличе Server Action. Якщо валідація не пройде, action поверне об'єкт із помилками, а компонент відобразить їх без перезавантаження сторінки.
Валідація типів і перетворення значень — різні операції.
Наприклад, це поле:
<input name="estimateMinutes" type="number" />все одно передає значення як рядок:
"30"У схемі ми перетворюємо рядок на число за допомогою z.preprocess():
z.preprocess(
(value) => {
if (typeof value !== "string" || value.trim() === "") {
return value;
}
return Number(value);
},
z.number().int().min(1).max(480),
)Після цього:
"30" перетвориться на 30;
"30.5" буде відхилено як неціле число;
"-1" буде відхилено через мінімальне значення;
"abc" буде відхилено як некоректне число;
порожнє значення буде відхилено.
Перетворення потрібно виконувати всередині серверної валідації, а не покладатися лише на тип поля в HTML.
Для полів із фіксованим набором значень використовуйте z.enum():
const prioritySchema = z.enum(["low", "medium", "high"]);Така схема прийме лише три значення:
prioritySchema.parse("low"); // "low"
prioritySchema.parse("high"); // "high"Але значення "urgent" або порожній рядок буде відхилено.
Це важливо навіть тоді, коли у формі використовується <select>. Користувач може змінити запит вручну й передати інше значення.
Правильний порядок дій у Server Action:
Отримати сирі дані з FormData.
Передати їх до схеми валідації.
Повернути помилки, якщо дані некоректні.
Взяти перевірені дані з result.data.
Виконати мутацію.
const result = taskSchema.safeParse(rawData);
if (!result.success) {
return {
ok: false,
errors: getErrors(result.error),
};
}
// Мутація виконується лише після успішної валідації
await saveTask(result.data);Не слід спочатку записувати дані в базу, а потім перевіряти їх. Валідація має бути захисною межею між зовнішнім вводом і внутрішньою логікою застосунку.
Zod може створити TypeScript-тип на основі схеми:
type TaskInput = z.infer<typeof taskSchema>;Для наведеного прикладу TaskInput матиме приблизно таку структуру:
type TaskInput = {
title: string;
priority: "low" | "medium" | "high";
estimateMinutes: number;
};Такий тип відображає саме результат валідації, а не сирі дані з форми. Це корисно, коли перевірений об'єкт передається до функції збереження:
async function saveTask(task: TaskInput) {
// Збереження перевіреного завдання
}Server Action може бути викликана не лише через конкретну форму. Тому серверна валідація повинна бути єдиним обов'язковим місцем перевірки.
HTML-валідація корисна для зручності користувача:
<input
name="title"
required
minLength={3}
maxLength={80}
/>Але ці атрибути не замінюють перевірку в Server Action. Клієнтська валідація покращує інтерфейс, а серверна забезпечує коректність і безпеку даних.
type TaskInput = {
title: string;
};
export async function createTask(task: TaskInput) {
// TypeScript не перевіряє фактичний запит під час виконання
}Тип TypeScript не гарантує, що зовнішні дані справді відповідають цьому типу. Для цього потрібна runtime-валідація через схему.
required, min, max та pattern можна обійти, надіславши запит безпосередньо на сервер.
Такі атрибути потрібно використовувати, але обов'язково повторювати правила на сервері.
safeParseНеправильно:
const title = formData.get("title") as string;
// Мутація до перевірки
await saveTask({ title });
const result = taskSchema.safeParse({ title });Приведення as string змінює лише думку TypeScript про значення. Воно не перетворює null або File на коректний рядок.
Правильно:
const result = taskSchema.safeParse({
title: formData.get("title"),
});
if (!result.success) {
return {
ok: false,
message: "Некоректна назва",
};
}
await saveTask(result.data);Перевірка на кшталт if (!title) не контролює:
мінімальну довжину;
максимальну довжину;
допустимий формат;
допустимий набір значень;
правильний тип.
Схема має описувати всі правила, необхідні для мутації.
parse() без обробки помилокparse() викидає виняток, якщо дані некоректні. Це може бути доречно в окремих внутрішніх сценаріях, але для обробки введення форми зазвичай зручніше використовувати safeParse() і повернути помилки у стан форми.
Дані з FormData потрібно вважати ненадійними.
TypeScript не виконує перевірку зовнішніх даних під час роботи програми.
Server Action має перевіряти дані до будь-якої мутації.
Zod дозволяє описати типи, обмеження значень і перетворення.
safeParse() повертає або перевірені дані, або структуровані помилки.
Після успішної перевірки потрібно використовувати result.data, а не сирі дані.
HTML-валідація покращує досвід користувача, але не замінює серверну валідацію.