Продвинутая валидация с Mantine. Массивы
Ранее в этом курсе мы освоили базовую работу с @mantine/form и простую валидацию. Теперь пришло время подняться на новый уровень. В этом уроке мы разберемся с асинхронной валидацией (проверка email на сервере), динамическими массивами полей (добавление/удаление элементов) и продвинутыми сценариями, которые встречаются в реальных проектах.
Асинхронная валидация
В реальных приложениях часто нужно проверять данные на сервере — например, занят ли email или логин. useForm поддерживает асинхронные функции валидации «из коробки».
Базовый пример: проверка email на сервере
import { useForm } from '@mantine/form';
import { TextInput, Button, Box } from '@mantine/core';
function AsyncValidationForm() {
// Имитация запроса к серверу
const checkEmailExists = async (email: string) => {
// В реальном проекте здесь будет fetch('/api/check-email', { ... })
await new Promise((resolve) => setTimeout(resolve, 1000));
// Имитация: email test@example.com уже занят
if (email === 'test@example.com') {
return 'Этот email уже зарегистрирован';
}
return null;
};
const form = useForm({
mode: 'uncontrolled',
initialValues: {
email: '',
},
validate: {
// Асинхронная функция валидации
email: async (value) => {
if (!value) {
return 'Email обязателен';
}
if (!/^\S+@\S+$/.test(value)) {
return 'Некорректный формат email';
}
// Асинхронная проверка на сервере
const error = await checkEmailExists(value);
return error;
},
},
validateInputOnChange: true, // Валидация при вводе
});
return (
<Box component="form" onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
label="Email"
placeholder="your@email.com"
withAsterisk
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Button type="submit" mt="md" loading={form.isValidating()}>
Отправить
</Button>
</Box>
);
}
Важные моменты:
- Асинхронная валидация автоматически показывает индикатор загрузки через
form.isValidating() - Можно комбинировать синхронные и асинхронные проверки в одной функции — сначала быстрые (формат), потом медленные (запрос к серверу)
- Поле будет валидироваться повторно при каждом изменении (если включено
validateInputOnChange)
Валидация нескольких полей с учетом друг друга
Иногда нужно валидировать поля в связке с асинхронными данными:
const form = useForm({
mode: 'uncontrolled',
initialValues: {
username: '',
email: '',
},
validate: {
username: async (value, values) => {
if (!value) return 'Имя пользователя обязательно';
if (value.length < 3) return 'Минимум 3 символа';
// Проверяем, не совпадает ли username с email (без @)
const emailLocal = values.email?.split('@')[0];
if (emailLocal && value.toLowerCase() === emailLocal.toLowerCase()) {
return 'Имя пользователя не должно совпадать с локальной частью email';
}
// Асинхронная проверка на сервере
const isTaken = await checkUsername(value);
return isTaken ? 'Имя пользователя уже занято' : null;
},
email: async (value) => {
// ... валидация email
},
},
});
Работа с массивами полей (динамические списки)
Одна из самых мощных возможностей @mantine/form — работа с массивами объектов. Это позволяет создавать динамические списки, например, список сотрудников, телефонных номеров или адресов.
Базовый пример: список сотрудников
import { useForm } from '@mantine/form';
import { TextInput, Button, Box, Group, Paper, ActionIcon } from '@mantine/core';
import { IconTrash, IconPlus } from '@tabler/icons-react';
interface Employee {
name: string;
position: string;
}
function EmployeeListForm() {
const form = useForm({
mode: 'uncontrolled',
initialValues: {
employees: [{ name: '', position: '' }] as Employee[],
},
validate: {
employees: {
name: (value) => (value.length < 2 ? 'Минимум 2 символа' : null),
position: (value) => (value.length < 2 ? 'Укажите должность' : null),
},
},
});
// Добавление нового сотрудника
const addEmployee = () => {
form.insertListItem('employees', { name: '', position: '' });
};
// Удаление сотрудника по индексу
const removeEmployee = (index: number) => {
form.removeListItem('employees', index);
};
// Получаем массив сотрудников
const employees = form.getValues().employees;
return (
<Box component="form" onSubmit={form.onSubmit((values) => console.log(values))}>
{employees.map((_, index) => (
<Paper key={form.key(`employees.${index}`)} withBorder p="md" mt="md">
<Group align="flex-end">
<TextInput
label="Имя сотрудника"
placeholder="Иван Петров"
style={{ flex: 1 }}
key={form.key(`employees.${index}.name`)}
{...form.getInputProps(`employees.${index}.name`)}
/>
<TextInput
label="Должность"
placeholder="Разработчик"
style={{ flex: 1 }}
key={form.key(`employees.${index}.position`)}
{...form.getInputProps(`employees.${index}.position`)}
/>
<ActionIcon
color="red"
onClick={() => removeEmployee(index)}
disabled={employees.length === 1}
mt="auto"
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Paper>
))}
<Group justify="space-between" mt="md">
<Button
variant="outline"
leftSection={<IconPlus size={18} />}
onClick={addEmployee}
>
Добавить сотрудника
</Button>
<Button type="submit">Сохранить</Button>
</Group>
</Box>
);
}
Методы для работы с массивами
useForm предоставляет несколько методов для управления массивами:
| Метод | Описание | Пример |
|---|---|---|
insertListItem(path, item, index) |
Вставляет элемент в массив | form.insertListItem('employees', { name: '' }) |
removeListItem(path, index) |
Удаляет элемент по индексу | form.removeListItem('employees', 0) |
reorderListItem(path, from, to) |
Перемещает элемент в массиве | form.reorderListItem('employees', 0, 2) |
setListItem(path, index, item) |
Обновляет элемент по индексу | form.setListItem('employees', 0, { name: 'John' }) |
Пример с перемещением элементов
import { ActionIcon, Group } from '@mantine/core';
import { IconArrowUp, IconArrowDown } from '@tabler/icons-react';
function EmployeeItem({ index, total, form }: { index: number; total: number; form: any }) {
const moveUp = () => {
if (index > 0) {
form.reorderListItem('employees', index, index - 1);
}
};
const moveDown = () => {
if (index < total - 1) {
form.reorderListItem('employees', index, index + 1);
}
};
return (
<Group>
{/* ... поля формы ... */}
<ActionIcon onClick={moveUp} disabled={index === 0}>
<IconArrowUp size={18} />
</ActionIcon>
<ActionIcon onClick={moveDown} disabled={index === total - 1}>
<IconArrowDown size={18} />
</ActionIcon>
</Group>
);
}
Валидация массивов
Валидация массивов работает аналогично валидации объектов, но с использованием индексов:
const form = useForm({
mode: 'uncontrolled',
initialValues: {
employees: [{ name: '', position: '' }],
},
validate: {
// Валидация каждого элемента массива
employees: {
name: (value) => (value.length < 2 ? 'Минимум 2 символа' : null),
position: (value) => (value.length < 2 ? 'Укажите должность' : null),
},
},
});
Валидация всего массива (например, минимальное количество)
Иногда нужно проверить массив целиком — например, что добавлен хотя бы один элемент:
const form = useForm({
mode: 'uncontrolled',
initialValues: {
employees: [] as Employee[],
},
validate: {
// Валидация всего массива
employees: (value) => {
if (value.length === 0) {
return 'Добавьте хотя бы одного сотрудника';
}
return null;
},
},
});
Динамическая валидация с form.validateField()
Иногда нужно запустить валидацию конкретного поля вручную — например, после изменения другого поля:
function SmartForm() {
const form = useForm({
mode: 'uncontrolled',
initialValues: {
country: '',
city: '',
},
validate: {
country: (value) => (!value ? 'Выберите страну' : null),
city: (value, values) => {
if (!values.country) return 'Сначала выберите страну';
if (!value) return 'Введите город';
return null;
},
},
});
const handleCountryChange = (value: string) => {
form.setFieldValue('country', value);
// Принудительно валидируем поле city после изменения country
form.validateField('city');
};
return (
// ... компоненты
);
}
Собери свой код. Запусти сайт!
От наброска на салфетке до первого работающего лендинга. Наш онлайн-курс «Веб-верстка с нуля и до профессионала» — это интенсивный трек, где ты не будешь зубрить теорию, а с первого дня начнешь превращать идеи в чистый HTML и CSS.
Собери свой первый проект под руководством практикующих разработчиков.
Обработка ошибок с бэкенда
После отправки формы на сервер могут возникнуть ошибки, не связанные с валидацией (например, ошибка авторизации). Их можно установить вручную:
function SubmitForm() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { email: '', password: '' },
});
const handleSubmit = async (values: any) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(values),
});
if (!response.ok) {
const error = await response.json();
// Устанавливаем ошибки, полученные с сервера
if (error.field === 'email') {
form.setFieldError('email', 'Пользователь с таким email не найден');
}
if (error.field === 'password') {
form.setFieldError('password', 'Неверный пароль');
}
if (error.message) {
// Ошибка не привязана к полю — показываем ее в уведомлении
notifications.show({
title: 'Ошибка',
message: error.message,
color: 'red',
});
}
return;
}
// Успешная отправка
console.log('Успешно!');
} catch (error) {
console.error('Ошибка сети:', error);
}
};
// ... остальной код формы
}
Полный пример: форма заказа с динамическими позициями
Давайте соберем все изученное в одном примере — форма заказа с возможностью добавлять/удалять товары, валидацией каждого поля и асинхронной проверкой на бэкенде.
import { useForm } from '@mantine/form';
import {
TextInput,
NumberInput,
Button,
Box,
Group,
Paper,
ActionIcon,
Select,
Loader,
} from '@mantine/core';
import { IconTrash, IconPlus } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
interface OrderItem {
product: string;
quantity: number;
price: number;
}
interface OrderFormValues {
clientName: string;
clientEmail: string;
items: OrderItem[];
total: number;
}
function OrderForm() {
const form = useForm<OrderFormValues>({
mode: 'uncontrolled',
initialValues: {
clientName: '',
clientEmail: '',
items: [{ product: '', quantity: 1, price: 0 }],
total: 0,
},
validate: {
clientName: (value) => (value.length < 2 ? 'Имя клиента обязательно' : null),
clientEmail: async (value) => {
if (!value) return 'Email обязателен';
if (!/^\S+@\S+$/.test(value)) return 'Некорректный email';
// Имитация проверки на сервере
await new Promise((resolve) => setTimeout(resolve, 500));
return null;
},
items: {
product: (value) => (!value ? 'Выберите товар' : null),
quantity: (value) => (value < 1 ? 'Минимум 1' : null),
price: (value) => (value < 0 ? 'Цена не может быть отрицательной' : null),
},
},
validateInputOnChange: ['clientName', 'clientEmail'],
});
// Добавление позиции
const addItem = () => {
form.insertListItem('items', { product: '', quantity: 1, price: 0 });
};
// Удаление позиции
const removeItem = (index: number) => {
form.removeListItem('items', index);
// Пересчитываем итоговую сумму
calculateTotal();
};
// Расчет итоговой суммы
const calculateTotal = () => {
const items = form.getValues().items;
const total = items.reduce((sum, item) => sum + (item.quantity || 0) * (item.price || 0), 0);
form.setFieldValue('total', total);
};
// Обновляем сумму при изменении количества или цены
const handleItemChange = (index: number, field: keyof OrderItem, value: any) => {
form.setFieldValue(`items.${index}.${field}`, value);
calculateTotal();
};
// Отправка формы
const handleSubmit = async (values: OrderFormValues) => {
try {
// Имитация отправки на сервер
await new Promise((resolve) => setTimeout(resolve, 1500));
notifications.show({
title: 'Успешно!',
message: `Заказ для ${values.clientName} создан на сумму ${values.total} ₽`,
color: 'green',
});
form.reset();
} catch (error) {
notifications.show({
title: 'Ошибка',
message: 'Не удалось создать заказ',
color: 'red',
});
}
};
return (
<Box component="form" onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Имя клиента"
placeholder="Иван Петров"
withAsterisk
key={form.key('clientName')}
{...form.getInputProps('clientName')}
/>
<TextInput
label="Email клиента"
placeholder="client@email.com"
withAsterisk
mt="md"
key={form.key('clientEmail')}
{...form.getInputProps('clientEmail')}
/>
<Paper withBorder p="md" mt="xl" bg="gray.0">
<Group justify="space-between" mb="md">
<strong>Товары в заказе</strong>
<Button
variant="outline"
size="xs"
leftSection={<IconPlus size={16} />}
onClick={addItem}
>
Добавить товар
</Button>
</Group>
{form.getValues().items.map((_, index) => (
<Group key={form.key(`items.${index}`)} align="flex-end" gap="sm" mt="xs">
<Select
label="Товар"
placeholder="Выберите товар"
data={[
{ value: 'laptop', label: 'Ноутбук' },
{ value: 'phone', label: 'Смартфон' },
{ value: 'tablet', label: 'Планшет' },
]}
style={{ flex: 2 }}
key={form.key(`items.${index}.product`)}
{...form.getInputProps(`items.${index}.product`)}
/>
<NumberInput
label="Количество"
placeholder="1"
min={0}
style={{ flex: 1 }}
key={form.key(`items.${index}.quantity`)}
{...form.getInputProps(`items.${index}.quantity`)}
onChange={(value) => handleItemChange(index, 'quantity', value)}
/>
<NumberInput
label="Цена"
placeholder="0"
min={0}
style={{ flex: 1 }}
key={form.key(`items.${index}.price`)}
{...form.getInputProps(`items.${index}.price`)}
onChange={(value) => handleItemChange(index, 'price', value)}
/>
<ActionIcon
color="red"
mt="auto"
onClick={() => removeItem(index)}
disabled={form.getValues().items.length === 1}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
))}
<Group justify="flex-end" mt="md">
<TextInput
label="Итого"
value={`${form.getValues().total} ₽`}
disabled
styles={{ input: { fontWeight: 700, fontSize: '1.2rem' } }}
/>
</Group>
</Paper>
<Group justify="flex-end" mt="xl">
<Button variant="outline" onClick={() => form.reset()}>
Сбросить
</Button>
<Button type="submit" loading={form.isValidating()}>
Создать заказ
</Button>
</Group>
</Box>
);
}
export default OrderForm;
Итоги
Сегодня вы научились:
- Реализовывать асинхронную валидацию с проверкой на сервере
- Работать с динамическими массивами полей — добавление, удаление, перемещение
- Валидировать отдельные поля и целые массивы
- Обрабатывать ошибки, приходящие с бэкенда
- Создавать сложные формы с пересчетом итоговых значений
Что должно быть в вашем проекте после этого урока:
- Реализована асинхронная валидация хотя бы одного поля
- Создана форма с динамическим списком (добавление/удаление элементов)
- Настроена валидация для каждого элемента массива
- Использован метод
reorderListItem()для перемещения элементов - Реализован пересчет вычисляемых значений (например, итоговой суммы)
- Настроена обработка ошибок с бэкенда через
setFieldError()
В следующем уроке мы перейдем к работе с компонентами ввода — разберем все типовые компоненты Mantine (TextInput, Select, Checkbox, NumberInput, FileInput, Radio, Switch), их пропсы и лучшие практики использования.