Клиентская валидация проверяет формат, длину строк, обязательность полей и другие базовые ограничения. Однако только сервер обладает полной информацией о состоянии системы:
По этой причине серверная валидация считается окончательной и авторитетной.
TanStack Query предоставляет удобные механизмы для интеграции серверной валидации через:
useMutation;Ошибка валидации — это не исключительная ситуация. Это нормальное состояние формы.
Например:
В TanStack Query подобные ответы сервера обычно обрабатываются внутри
mutation.
const mutation = useMutation({
mutationFn: createUser
})
Сервер может вернуть:
{
"message": "Validation failed",
"errors": {
"email": ["Email already exists"],
"password": ["Password is too weak"]
}
}
На практике API часто возвращают ошибки в одном из следующих форматов.
{
"message": "The given data was invalid.",
"errors": {
"email": [
"The email has already been taken."
]
}
}
{
"error": {
"details": [
{
"path": ["email"],
"message": "Email is invalid"
}
]
}
}
{
"errors": {
"Email": [
"Email already exists"
]
}
}
{
"errors": [
{
"message": "Username already exists",
"path": ["username"]
}
]
}
Основная задача клиента — привести ответы к единому формату.
Базовая схема выглядит следующим образом:
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
Полезно создавать общий API-клиент.
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')
}
}
})
Некоторые формы валидируют данные ещё до отправки.
Например:
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 сервер будет получать запрос на каждый ввод символа.
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()
}
})
Иногда 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
)
}
})
Если сервер отклоняет изменения:
Частая проблема — параллельное редактирование.
Например:
Сервер может вернуть:
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']
})
}
})
В больших формах серверная проверка может происходить поэтапно.
Например:
Каждый шаг может быть отдельной 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 нужен только для сетевых ошибок.
const mutation = useMutation({
mutationFn: submitForm,
retry: (failureCount, error) => {
if (error.status === 422) {
return false
}
return failureCount < 3
}
})
TanStack Query позволяет централизовать обработку ошибок.
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)
}
})
На практике используются оба уровня.
Проверяет:
Проверяет:
Некоторые ограничения невозможно проверить локально.
Например:
const availabilityQuery = useQuery({
queryKey: [
'availability',
startDate,
endDate
],
queryFn: checkAvailability,
enabled: !!startDate && !!endDate
})
Проблема гонок особенно заметна в формах автосохранения.
Например:
В результате UI может стать неактуальным.
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 упрощает работу с асинхронной логикой.
try {
await mutation.mutateAsync(data)
navigate('/success')
} catch (error) {
console.error(error)
}
Сервер часто проверяет:
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 вместо 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
)
}
}
})
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
logout()
}
return Promise.reject(error)
}
)
В крупных приложениях обычно выделяются отдельные слои:
api.register()
api.login()
api.updateProfile()
normalizeErrors()
parseValidationErrors()
setError()
clearErrors()
useMutation()
useQuery()
invalidateQueries()
Такое разделение уменьшает связанность компонентов и упрощает поддержку системы.