Хотите заказать веб-сайт? Связаться с нами

Продвинутая валидация с Mantine. Массивы

Ранее в этом курсе мы освоили базовую работу с @mantine/form и простую валидацию. Теперь пришло время подняться на новый уровень. В этом уроке мы разберемся с асинхронной валидацией (проверка email на сервере), динамическими массивами полей (добавление/удаление элементов) и продвинутыми сценариями, которые встречаются в реальных проектах.

Продвинутая валидация с Mantine. Массивы

Асинхронная валидация

В реальных приложениях часто нужно проверять данные на сервере — например, занят ли 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>
  );
}

Важные моменты:

  1. Асинхронная валидация автоматически показывает индикатор загрузки через form.isValidating()
  2. Можно комбинировать синхронные и асинхронные проверки в одной функции — сначала быстрые (формат), потом медленные (запрос к серверу)
  3. Поле будет валидироваться повторно при каждом изменении (если включено 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;

Итоги

Сегодня вы научились:

  1. Реализовывать асинхронную валидацию с проверкой на сервере
  2. Работать с динамическими массивами полей — добавление, удаление, перемещение
  3. Валидировать отдельные поля и целые массивы
  4. Обрабатывать ошибки, приходящие с бэкенда
  5. Создавать сложные формы с пересчетом итоговых значений

Что должно быть в вашем проекте после этого урока:

  1. Реализована асинхронная валидация хотя бы одного поля
  2. Создана форма с динамическим списком (добавление/удаление элементов)
  3. Настроена валидация для каждого элемента массива
  4. Использован метод reorderListItem() для перемещения элементов
  5. Реализован пересчет вычисляемых значений (например, итоговой суммы)
  6. Настроена обработка ошибок с бэкенда через setFieldError()

В следующем уроке мы перейдем к работе с компонентами ввода — разберем все типовые компоненты Mantine (TextInput, Select, Checkbox, NumberInput, FileInput, Radio, Switch), их пропсы и лучшие практики использования.

Теги: