Валидация на основе серверных данных

Клиентская валидация проверяет формат, длину строк, обязательность полей и другие базовые ограничения. Однако только сервер обладает полной информацией о состоянии системы:

  • существование пользователя;
  • уникальность email;
  • допустимость перехода состояния;
  • наличие прав доступа;
  • ограничения бизнес-логики;
  • актуальность данных;
  • ограничения по времени;
  • проверка токенов и сессий.

По этой причине серверная валидация считается окончательной и авторитетной.

TanStack Query предоставляет удобные механизмы для интеграции серверной валидации через:

  • useMutation;
  • обработку ошибок;
  • optimistic updates;
  • rollback;
  • повторные запросы;
  • инвалидирование кеша;
  • глобальные обработчики ошибок.

Серверные ошибки как часть состояния приложения

Ошибка валидации — это не исключительная ситуация. Это нормальное состояние формы.

Например:

  • email уже занят;
  • пароль слишком простой;
  • товар закончился;
  • username недоступен;
  • запись была изменена другим пользователем.

В TanStack Query подобные ответы сервера обычно обрабатываются внутри mutation.

const mutation = useMutation({
    mutationFn: createUser
})

Сервер может вернуть:

{
    "message": "Validation failed",
    "errors": {
        "email": ["Email already exists"],
        "password": ["Password is too weak"]
    }
}

Типичная структура серверной ошибки

На практике API часто возвращают ошибки в одном из следующих форматов.

Laravel

{
    "message": "The given data was invalid.",
    "errors": {
        "email": [
            "The email has already been taken."
        ]
    }
}

Express + Joi

{
    "error": {
        "details": [
            {
                "path": ["email"],
                "message": "Email is invalid"
            }
        ]
    }
}

ASP.NET

{
    "errors": {
        "Email": [
            "Email already exists"
        ]
    }
}

GraphQL

{
    "errors": [
        {
            "message": "Username already exists",
            "path": ["username"]
        }
    ]
}

Основная задача клиента — привести ответы к единому формату.


Обработка ошибок через useMutation

Базовая схема выглядит следующим образом:

const mutation = useMutation({
    mutationFn: async (data) => {
        const response = await fetch('/api/users', {
            method: 'POST',
            body: JSON.stringify(data),
            headers: {
                'Content-Type': 'application/json'
            }
        })

        if (!response.ok) {
            const errorData = await response.json()
            throw errorData
        }

        return response.json()
    }
})

После этого ошибки доступны через:

mutation.error

или:

mutation.isError

Связывание серверных ошибок с формой

Наиболее распространённый сценарий — интеграция с React Hook Form.

Пример полной интеграции

import { useForm } from 'react-hook-form'
import { useMutation } from '@tanstack/react-query'

function RegisterForm() {
    const {
        register,
        handleSubmit,
        setError,
        formState: { errors }
    } = useForm()

    const mutation = useMutation({
        mutationFn: async (formData) => {
            const response = await fetch('/api/register', {
                method: 'POST',
                body: JSON.stringify(formData),
                headers: {
                    'Content-Type': 'application/json'
                }
            })

            if (!response.ok) {
                throw await response.json()
            }

            return response.json()
        },

        onError: (error) => {
            if (error.errors) {
                Object.entries(error.errors).forEach(([field, messages]) => {
                    setError(field, {
                        type: 'server',
                        message: messages[0]
                    })
                })
            }
        }
    })

    const onSub mit = (data) => {
        mutation.mutate(data)
    }

    return (
        <form onSub mit={handleSubmit(onSubmit)}>
            <input {...register('email')} />

            {errors.email && (
                <p>{errors.email.message}</p>
            )}

            <button type="submit">
                Register
            </button>
        </form>
    )
}

Нормализация серверных ошибок

Разные backend-framework возвращают ошибки в разных структурах. Поэтому полезно создавать слой нормализации.

Универсальный парсер ошибок

function normalizeValidationErrors(error) {
    if (error.errors) {
        return error.errors
    }

    if (error.error?.details) {
        return error.error.details.reduce((acc, item) => {
            const field = item.path[0]

            acc[field] = [item.message]

            return acc
        }, {})
    }

    return {}
}

Использование:

onError: (error) => {
    const normalized = normalizeValidationErrors(error)

    Object.entries(normalized).forEach(([field, messages]) => {
        setError(field, {
            type: 'server',
            message: messages[0]
        })
    })
}

Разделение ошибок по типам

Ошибки сервера желательно разделять на категории.

Ошибки валидации

422 Unprocessable Entity

Ошибки авторизации

401 Unauthorized
403 Forbidden

Конфликт состояния

409 Conflict

Ошибки сервера

500 Internal Server Error

Централизованная обработка HTTP-ошибок

Полезно создавать общий API-клиент.

Пример fetch-обёртки

export async function api(url, options = {}) {
    const response = await fetch(url, options)

    let data = null

    try {
        data = await response.json()
    } catch {}

    if (!response.ok) {
        throw {
            status: response.status,
            data
        }
    }

    return data
}

Использование:

const mutation = useMutation({
    mutationFn: (payload) =>
        api('/api/register', {
            method: 'POST',
            body: JSON.stringify(payload),
            headers: {
                'Content-Type': 'application/json'
            }
        }),

    onError: (error) => {
        if (error.status === 422) {
            console.log('Validation error')
        }
    }
})

Проверка уникальности данных через useQuery

Некоторые формы валидируют данные ещё до отправки.

Например:

  • username;
  • email;
  • slug;
  • номер телефона.

Проверка username

const usernameQuery = useQuery({
    queryKey: ['username-check', username],
    queryFn: async () => {
        const response = await fetch(
            `/api/check-username?value=${username}`
        )

        return response.json()
    },

    enabled: username.length > 2
})

Дебаунсинг серверной валидации

Без debounce сервер будет получать запрос на каждый ввод символа.

Пример debounce

const [debouncedUsername, setDebouncedUsername] =
    useState('')

useEffect(() => {
    const timer = setTimeout(() => {
        setDebouncedUsername(username)
    }, 500)

    return () => clearTimeout(timer)
}, [username])

Использование:

const query = useQuery({
    queryKey: ['username', debouncedUsername],
    queryFn: checkUsername,
    enabled: !!debouncedUsername
})

Отмена устаревших запросов

При быстром вводе старые запросы могут приходить позже новых.

TanStack Query поддерживает AbortController.

const query = useQuery({
    queryKey: ['username', username],

    queryFn: async ({ signal }) => {
        const response = await fetch(
            `/api/check?username=${username}`,
            { signal }
        )

        return response.json()
    }
})

Серверная валидация после optimistic update

Иногда UI обновляется раньше ответа сервера.

Пример

const mutation = useMutation({
    mutationFn: updateProfile,

    onMutate: async (newProfile) => {
        await queryClient.cancelQueries({
            queryKey: ['profile']
        })

        const previous =
            queryClient.getQueryData(['profile'])

        queryClient.setQueryData(
            ['profile'],
            newProfile
        )

        return { previous }
    },

    onError: (error, variables, context) => {
        queryClient.setQueryData(
            ['profile'],
            context.previous
        )
    }
})

Если сервер отклоняет изменения:

  • выполняется rollback;
  • UI возвращается к предыдущему состоянию;
  • показывается ошибка.

Валидация конфликтов данных

Частая проблема — параллельное редактирование.

Например:

  1. Пользователь открыл форму.
  2. Данные изменились другим пользователем.
  3. Старые данные отправляются обратно.

Сервер может вернуть:

409 Conflict

Обработка конфликта

onError: (error) => {
    if (error.status === 409) {
        alert('Data was changed by another user')
    }
}

Валидация на основе актуальности кеша

Иногда форма зависит от данных, находящихся в кеше.

Например:

  • список доступных ролей;
  • ограничения тарифов;
  • остатки товаров;
  • лимиты.

Перед отправкой формы можно проверять кеш.

const roles = queryClient.getQueryData(['roles'])

Инвалидирование после успешной мутации

После успешной серверной валидации данные часто устаревают.

Пример

const mutation = useMutation({
    mutationFn: createPost,

    onSuccess: () => {
        queryClient.invalidateQueries({
            queryKey: ['posts']
        })
    }
})

Валидация многошаговых форм

В больших формах серверная проверка может происходить поэтапно.

Например:

  1. Проверка email.
  2. Проверка SMS-кода.
  3. Проверка документов.
  4. Финальное создание аккаунта.

Каждый шаг может быть отдельной mutation.

const verifyEmailMutation = useMutation({
    mutationFn: verifyEmail
})

const verifyCodeMutation = useMutation({
    mutationFn: verifyCode
})

const createAccountMutation = useMutation({
    mutationFn: createAccount
})

Повторные попытки и валидация

Ошибки валидации не должны повторяться автоматически.

Поэтому retry желательно отключать.

const mutation = useMutation({
    mutationFn: submitForm,
    retry: false
})

Условный retry

Иногда retry нужен только для сетевых ошибок.

const mutation = useMutation({
    mutationFn: submitForm,

    retry: (failureCount, error) => {
        if (error.status === 422) {
            return false
        }

        return failureCount < 3
    }
})

Глобальная обработка ошибок MutationCache

TanStack Query позволяет централизовать обработку ошибок.

Конфигурация QueryClient

const queryClient = new QueryClient({
    mutationCache: new MutationCache({
        onError: (error) => {
            console.error(error)
        }
    })
})

Выделение ошибок уровня формы

Некоторые ошибки не относятся к конкретному полю.

Например:

  • неверная комбинация логина и пароля;
  • превышен лимит операций;
  • аккаунт заблокирован.

Пример

const [formError, setFormError] =
    useState(null)

const mutation = useMutation({
    mutationFn: login,

    onError: (error) => {
        setFormError(error.message)
    }
})

Смешанная клиентская и серверная валидация

На практике используются оба уровня.

Клиент

Проверяет:

  • пустые поля;
  • минимальную длину;
  • формат email;
  • допустимые символы.

Сервер

Проверяет:

  • уникальность;
  • бизнес-правила;
  • доступы;
  • состояние БД;
  • ограничения домена.

Валидация зависимых полей

Некоторые ограничения невозможно проверить локально.

Например:

  • свободен ли диапазон дат;
  • можно ли перевести средства;
  • доступен ли товар;
  • существует ли связь между сущностями.

Проверка диапазона дат

const availabilityQuery = useQuery({
    queryKey: [
        'availability',
        startDate,
        endDate
    ],

    queryFn: checkAvailability,

    enabled: !!startDate && !!endDate
})

Серверная валидация и race conditions

Проблема гонок особенно заметна в формах автосохранения.

Например:

  1. Отправляется запрос A.
  2. Пользователь меняет данные.
  3. Отправляется запрос B.
  4. Ответ A приходит позже B.

В результате UI может стать неактуальным.


Защита от race conditions

Использование timestamp

const currentRequest = useRef(0)

async function save(data) {
    const requestId = ++currentRequest.current

    const result = await mutation.mutateAsync(data)

    if (requestId !== currentRequest.current) {
        return
    }

    applyResult(result)
}

Использование mutateAsync

mutateAsync упрощает работу с асинхронной логикой.

try {
    await mutation.mutateAsync(data)

    navigate('/success')
} catch (error) {
    console.error(error)
}

Валидация файлов

Сервер часто проверяет:

  • MIME-тип;
  • размер;
  • содержимое файла;
  • вирусы;
  • расширение.

Пример загрузки файла

const uploadMutation = useMutation({
    mutationFn: async (file) => {
        const formData = new FormData()

        formData.append('file', file)

        const response = await fetch('/upload', {
            method: 'POST',
            body: formData
        })

        if (!response.ok) {
            throw await response.json()
        }

        return response.json()
    }
})

Интеграция с Axios

Многие проекты используют Axios вместо fetch.

Пример

const mutation = useMutation({
    mutationFn: async (data) => {
        const response = await axios.post(
            '/api/register',
            data
        )

        return response.data
    },

    onError: (error) => {
        if (error.response?.status === 422) {
            console.log(
                error.response.data.errors
            )
        }
    }
})

Использование interceptor для валидации

axios.interceptors.response.use(
    response => response,

    error => {
        if (error.response?.status === 401) {
            logout()
        }

        return Promise.reject(error)
    }
)

Архитектурный подход к серверной валидации

В крупных приложениях обычно выделяются отдельные слои:

API Layer

api.register()
api.login()
api.updateProfile()

Validation Layer

normalizeErrors()
parseValidationErrors()

Form Layer

setError()
clearErrors()

Query Layer

useMutation()
useQuery()
invalidateQueries()

Такое разделение уменьшает связанность компонентов и упрощает поддержку системы.