Abortable валидация

Abortable-валідация — подход, при котором выполняемая проверка может быть принудительно остановлена до завершения. В контексте Yup и yupResolver это особенно важно при асинхронных сценариях:

  • проверка уникальности логина;
  • запросы к API;
  • debounce-валидация;
  • отмена устаревших запросов;
  • предотвращение race condition;
  • оптимизация производительности форм.

При работе с React Hook Form и yupResolver проблема становится особенно заметной: пользователь быстро изменяет поле, а предыдущая асинхронная валидация всё ещё выполняется. В результате старый ответ может перезаписать более новый.


Проблема асинхронной валидации

Типичный пример:

const schema = yup.object({
  username: yup
    .string()
    .required()
    .test(
      'username-check',
      'Логин уже занят',
      async (value) => {
        const response = await fetch(`/api/check/${value}`)
        const data = await response.json()

        return data.available
      }
    )
})

Если пользователь быстро вводит:

a
ab
abc
abcd

то отправятся четыре запроса.

Сервер может ответить в произвольном порядке:

abcd -> 200ms
ab   -> 800ms
abc  -> 500ms
a    -> 1000ms

Последний пришедший ответ способен испортить актуальное состояние формы.


Race Condition

Race condition возникает, когда старый асинхронный запрос завершился позже нового.

Пример:

1. Проверяем "alex"
2. Пользователь вводит "alexander"
3. Проверка "alexander" успешно завершилась
4. Старый запрос "alex" завершился позже
5. UI показывает ошибку для старого значения

В результате:

  • форма содержит новое значение;
  • ошибка относится к старому значению;
  • состояние становится неконсистентным.

AbortController

Современный JavaScript предоставляет встроенный механизм отмены запросов — AbortController.

Базовый пример:

const controller = new AbortController()

fetch('/api/users', {
  signal: controller.signal
})

controller.abort()

После вызова:

controller.abort()

запрос прерывается.


AbortSignal

Каждый AbortController создаёт объект:

controller.signal

Этот объект:

  • передаётся в fetch;
  • отслеживает состояние отмены;
  • генерирует событие abort.

Пример:

signal.aborted

Интеграция AbortController с Yup

Базовая схема

import * as yup from 'yup'
let controller = null

const schema = yup.object({
  username: yup.string().test(
    'username-check',
    'Логин занят',
    async (value) => {
      if (controller) {
        controller.abort()
      }

      controller = new AbortController()

      try {
        const response = await fetch(
          `/api/check/${value}`,
          {
            signal: controller.signal
          }
        )

        const data = await response.json()

        return data.available
      } catch (error) {
        if (error.name === 'AbortError') {
          return true
        }

        throw error
      }
    }
  )
})

Почему return true при AbortError

При отмене старой проверки:

if (error.name === 'AbortError') {
  return true
}

необходимо игнорировать её результат.

Причины:

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

Если вернуть false, форма покажет несуществующую ошибку.


Работа с yupResolver

Типичная интеграция:

import { yupResolver } from '@hookform/resolvers/yup'
const form = useForm({
  resolver: yupResolver(schema)
})

yupResolver полностью поддерживает асинхронные схемы:

await schema.validate()

Поэтому любые async test() работают автоматически.


Централизованное управление AbortController

Глобальная переменная подходит только для простых сценариев.

Более надёжный вариант — хранить контроллеры по полям.


Map контроллеров

const controllers = new Map()

Abortable helper

function createAbortableValidator(name, callback) {
  return async function(value) {
    if (controllers.has(name)) {
      controllers.get(name).abort()
    }

    const controller = new AbortController()

    controllers.set(name, controller)

    try {
      return await callback(value, controller.signal)
    } catch (error) {
      if (error.name === 'AbortError') {
        return true
      }

      throw error
    }
  }
}

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

const schema = yup.object({
  username: yup.string().test(
    'username',
    'Логин занят',
    createAbortableValidator(
      'username',
      async (value, signal) => {
        const response = await fetch(
          `/api/check/${value}`,
          { signal }
        )

        const data = await response.json()

        return data.available
      }
    )
  )
})

Независимая отмена для разных полей

Такой подход позволяет:

  • отменять только проверки конкретного поля;
  • не прерывать остальные запросы;
  • масштабировать большие формы.

Например:

username -> свой AbortController
email    -> свой AbortController
phone    -> свой AbortController

Debounce + Abortable validation

Даже abortable-подход не отменяет факт отправки большого количества запросов.

Дополнительно используется debounce.


Debounce функция

function debounce(fn, delay) {
  let timeout

  return (...args) => {
    clearTimeout(timeout)

    return new Promise((resolve) => {
      timeout = setTimeout(async () => {
        resolve(await fn(...args))
      }, delay)
    })
  }
}

Комбинация debounce и abort

const validateUsername = debounce(
  createAbortableValidator(
    'username',
    async (value, signal) => {
      const response = await fetch(
        `/api/check/${value}`,
        { signal }
      )

      const data = await response.json()

      return data.available
    }
  ),
  400
)

Полная схема

const schema = yup.object({
  username: yup
    .string()
    .required()
    .min(3)
    .test(
      'username',
      'Логин занят',
      validateUsername
    )
})

Поведение при быстром вводе

Последовательность:

1. Пользователь вводит символ
2. debounce откладывает запрос
3. Пользователь вводит новый символ
4. Старый timeout удаляется
5. Новый запрос становится актуальным
6. Старый fetch abort()
7. Выполняется только последняя проверка

Контекст Yup test

Внутри test() доступен контекст.

test(
  name,
  message,
  function(value) {
    console.log(this)
  }
)

Важные свойства context

this.parent

Текущие данные формы:

this.parent.email

this.path

Имя поля:

console.log(this.path)

this.createError()

Создание кастомной ошибки:

return this.createError({
  message: 'Ошибка сервера'
})

Abortable validation с createError

.test(
  'username',
  'Ошибка',
  async function(value) {
    try {
      const response = await fetch(...)

      return true
    } catch (error) {
      if (error.name === 'AbortError') {
        return true
      }

      return this.createError({
        message: 'Сервер недоступен'
      })
    }
  }
)

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

Крайне важно различать:

Тип Значение
Validation error Ошибка данных
AbortError Отмена запроса
Network error Ошибка сети
Server error Ошибка API

Ошибки сети

catch (error) {
  if (error.name === 'AbortError') {
    return true
  }

  if (error instanceof TypeError) {
    return this.createError({
      message: 'Нет соединения'
    })
  }

  throw error
}

Таймауты запросов

AbortController позволяет реализовывать timeout.


Timeout validation

async function fetchWithTimeout(url, options = {}) {
  const controller = new AbortController()

  const timeout = setTimeout(() => {
    controller.abort()
  }, 5000)

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    })

    return response
  } finally {
    clearTimeout(timeout)
  }
}

Timeout внутри Yup

.test(
  'api',
  'Ошибка проверки',
  async function(value) {
    try {
      const response = await fetchWithTimeout(
        `/api/check/${value}`
      )

      const data = await response.json()

      return data.available
    } catch (error) {
      if (error.name === 'AbortError') {
        return this.createError({
          message: 'Таймаут проверки'
        })
      }

      throw error
    }
  }
)

Проблема memory leak

Если контроллеры не очищать:

const controllers = new Map()

то возможна утечка памяти.


Очистка контроллеров

finally {
  controllers.delete(name)
}

Полный пример:

function createAbortableValidator(name, callback) {
  return async function(value) {
    if (controllers.has(name)) {
      controllers.get(name).abort()
    }

    const controller = new AbortController()

    controllers.set(name, controller)

    try {
      return await callback(value, controller.signal)
    } catch (error) {
      if (error.name === 'AbortError') {
        return true
      }

      throw error
    } finally {
      controllers.delete(name)
    }
  }
}

Проблема удаления актуального controller

В сложных сценариях предыдущий finally может удалить новый controller.

Пример:

1. request A started
2. request B started
3. request A finally -> delete()
4. request B controller исчез

Безопасное удаление

finally {
  if (controllers.get(name) === controller) {
    controllers.delete(name)
  }
}

Финальная версия helper

const controllers = new Map()

function createAbortableValidator(name, callback) {
  return async function(value) {
    if (controllers.has(name)) {
      controllers.get(name).abort()
    }

    const controller = new AbortController()

    controllers.set(name, controller)

    try {
      return await callback(
        value,
        controller.signal
      )
    } catch (error) {
      if (error.name === 'AbortError') {
        return true
      }

      throw error
    } finally {
      if (controllers.get(name) === controller) {
        controllers.delete(name)
      }
    }
  }
}

Abortable validation и React StrictMode

В React StrictMode некоторые операции могут вызываться дважды в development-режиме.

Это приводит к:

  • двойным запросам;
  • неожиданным abort;
  • нестабильным тестам.

Стабилизация validator

Частая ошибка:

const schema = yup.object({
  username: yup.string().test(...)
})

если схема создаётся при каждом рендере.


useMemo

Правильный подход:

const schema = useMemo(() => {
  return yup.object({
    username: yup.string().test(...)
  })
}, [])

Abortable validation и cache

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


Кэширование результатов

const cache = new Map()

Cached validator

async function validateUsername(value, signal) {
  if (cache.has(value)) {
    return cache.get(value)
  }

  const response = await fetch(
    `/api/check/${value}`,
    { signal }
  )

  const data = await response.json()

  cache.set(value, data.available)

  return data.available
}

TTL cache

Бесконечный cache опасен.


Cache с временем жизни

const cache = new Map()

function setCache(key, value) {
  cache.set(key, {
    value,
    expires: Date.now() + 60000
  })
}

function getCache(key) {
  const item = cache.get(key)

  if (!item) {
    return null
  }

  if (Date.now() > item.expires) {
    cache.delete(key)

    return null
  }

  return item.value
}

Abortable validation и UX

Неправильная реализация приводит к плохому пользовательскому опыту:

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

Рекомендуемые UX-практики

Не валидировать слишком рано

Плохо:

.min(1)

Лучше:

.min(3)

Использовать debounce

Оптимальные значения:

300–600ms

Игнорировать AbortError

Отмена — не ошибка.


Показывать loading state

formState.isValidating

isValidating

React Hook Form предоставляет состояние:

const {
  formState: {
    isValidating
  }
} = useForm()

Индикатор проверки

{
  isValidating && (
    <span>Проверка...</span>
  )
}

Abortable validation для нескольких API

Иногда проверка требует нескольких запросов.


Параллельная проверка

const [
  username,
  email
] = await Promise.all([
  fetch('/api/username'),
  fetch('/api/email')
])

Общий signal

const controller = new AbortController()

Promise.all([
  fetch('/a', {
    signal: controller.signal
  }),
  fetch('/b', {
    signal: controller.signal
  })
])

abort() остановит оба запроса.


Abortable validation и SSR

На сервере:

  • fetch может отличаться;
  • AbortController может отсутствовать;
  • среда Node.js зависит от версии.

Node.js поддержка

Полная встроенная поддержка:

Node.js 18+

Для старых версий требуются polyfill.


Polyfill

npm install abort-controller

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

import AbortController from 'abort-controller'

Архитектурный подход

Для больших приложений abortable-валидацию обычно выносят:

  • в сервисы;
  • в custom hooks;
  • в validation layer;
  • в API abstraction.

Пример сервисного слоя

class ValidationService {
  controllers = new Map()

  async validateUsername(value) {
    if (this.controllers.has('username')) {
      this.controllers
        .get('username')
        .abort()
    }

    const controller =
      new AbortController()

    this.controllers.set(
      'username',
      controller
    )

    const response = await fetch(
      `/api/check/${value}`,
      {
        signal: controller.signal
      }
    )

    const data = await response.json()

    return data.available
  }
}

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

const service =
  new ValidationService()

const schema = yup.object({
  username: yup.string().test(
    'username',
    'Логин занят',
    (value) =>
      service.validateUsername(value)
  )
})

Основные преимущества abortable validation

Производительность

  • меньше запросов;
  • меньше нагрузки на сервер;
  • меньше сетевого шума.

Консистентность

  • отсутствие race condition;
  • актуальные ошибки;
  • предсказуемое состояние формы.

UX

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

Масштабируемость

  • поддержка больших форм;
  • централизованная логика;
  • повторное использование validator.