Debounce и throttle валидации

Debounce валидации в связке с YupResolver обычно рассматривается как способ контролировать частоту запуска схемной проверки в формах, построенных на react-hook-form и Yup. В стандартной конфигурации YupResolver выполняет синхронную или асинхронную валидацию при каждом изменении состояния формы, что при сложных схемах и частых обновлениях может приводить к избыточным пересчётам и деградации UX.

YupResolver выступает адаптером между схемой Yup и механизмом валидации react-hook-form. При каждом событии, которое триггерит re-validation (onChange, onBlur, onSubmit), происходит:

  • сбор текущих значений формы
  • передача их в Yup schema
  • выполнение полной или частичной валидации
  • возврат ошибок в формате react-hook-form

Важная особенность заключается в том, что Yup не оптимизирует частичные пересчёты по умолчанию на уровне resolver — схема пересчитывается в рамках заданного контекста каждый раз заново.

При частых событиях ввода это приводит к ситуации, когда один пользовательский ввод вызывает цепочку повторных вычислений.


Причины применения debounce валидации

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

Ключевые сценарии:

  • поля с высокой частотой обновления (search input, filters)
  • сложные схемы Yup с вложенными объектами и массивами
  • асинхронные проверки (например, уникальность email)
  • формы с большим количеством полей, где каждое изменение триггерит перерасчёт

В таких случаях debounce снижает количество вызовов resolver, уменьшая нагрузку на CPU и улучшая отзывчивость интерфейса.


Базовая интеграция debounce через lodash

Один из распространённых способов — обёртывание вызова resolver в debounce-функцию.

import { useMemo } fr om "react";
import debounce fr om "lodash.debounce";
import { yupResolver } from "@hookform/resolvers/yup";

const createDebouncedResolver = (schema, delay = 300) => {
  const baseResolver = yupResolver(schema);

  return useMemo(() => {
    return debounce(async (values, context, options) => {
      return baseResolver(values, context, options);
    }, delay);
  }, [schema]);
};

Такой подход позволяет ограничить частоту выполнения схемной проверки, однако требует аккуратного управления жизненным циклом, так как debounce сохраняет состояние между рендерами.


Особенности использования debounce с react-hook-form

react-hook-form ожидает, что resolver будет чистой функцией без побочных эффектов. При использовании debounce возникают несколько технических нюансов:

  • результат валидации может приходить с задержкой относительно состояния формы
  • возможны рассинхронизации между UI и error state
  • необходимо предотвращать утечки памяти через cancel()

Корректная реализация должна учитывать очистку:

useEffect(() => {
  return () => {
    debouncedResolver.cancel();
  };
}, [debouncedResolver]);

Throttle как альтернатива debounce

Throttle ограничивает частоту вызова валидации фиксированным интервалом, не откладывая её до остановки ввода, а выполняя периодически.

Это полезно в сценариях, где важно получать промежуточную обратную связь:

  • динамическая проверка формы
  • визуальные индикаторы валидности
  • формы с live-preview

Пример интеграции:

import throttle from "lodash.throttle";
import { yupResolver } from "@hookform/resolvers/yup";

const createThrottledResolver = (schema, lim it = 200) => {
  const baseResolver = yupResolver(schema);

  const throttled = throttle((values, context, options, resolve) => {
    resolve(baseResolver(values, context, options));
  }, lim it);

  return async (values, context, options) => {
    return new Promise((resolve) => {
      throttled(values, context, options, resolve);
    });
  };
};

Throttle в контексте YupResolver менее предсказуем, чем debounce, поскольку ошибки могут обновляться с фиксированным лагом независимо от поведения пользователя.


Сравнение debounce и throttle в валидации форм

Debounce:

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

Throttle:

  • обеспечивает регулярные обновления состояния
  • подходит для live-валидации
  • более стабильный UX при длинных формах
  • может выполнять лишние проверки при медленном вводе

Использование debounce на уровне поля, а не resolver

Более управляемый подход заключается в отказе от оборачивания resolver и переносе debounce на уровень конкретных input-полей.

import { useEffect } from "react";
import debounce from "lodash.debounce";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";

const schema = yup.object({
  email: yup.string().email().required(),
});

const resolver = yupResolver(schema);

const Form = () => {
  const form = useForm({ resolver });

  useEffect(() => {
    const handler = debounce(() => {
      form.trigger("email");
    }, 400);

    const subscription = form.watch((value, { name }) => {
      if (name === "email") {
        handler();
      }
    });

    return () => {
      handler.cancel();
      subscription.unsubscribe();
    };
  }, [form]);

  return null;
};

Такой подход сохраняет чистоту resolver и позволяет контролировать частоту валидации точечно.


Асинхронные проверки и debounce

Особое внимание требуется при использовании Yup tests с асинхронными операциями:

email: yup
  .string()
  .email()
  .test("check-unique", async (value) => {
    const res = await fetch(`/api/check?email=${value}`);
    return res.ok;
  });

Без debounce такие проверки могут вызываться на каждый символ, создавая нагрузку на сервер. В таких случаях debounce становится не оптимизацией UI, а механизмом защиты backend.


Побочные эффекты частого вызова resolver

При отсутствии ограничения частоты вызовов возникают характерные проблемы:

  • увеличение времени реакции формы при вводе
  • блокировка UI при сложных схемах
  • рост количества сетевых запросов при async-валидации
  • нестабильное поведение ошибок (мигание error state)

Особенно заметно это при вложенных схемах Yup с массивами:

users: yup.array().of(
  yup.object({
    email: yup.string().email().required(),
    profile: yup.object({
      age: yup.number().min(18),
    }),
  })
);

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


Гибридный подход debounce + mode стратегии react-hook-form

react-hook-form позволяет задавать режим валидации:

  • onChange
  • onBlur
  • onSubmit
  • all

В сочетании с debounce можно уменьшить нагрузку без изменения resolver:

useForm({
  resolver: yupResolver(schema),
  mode: "onChange",
  reValidateMode: "onBlur",
});

В такой конфигурации debounce часто становится избыточным, если схема не перегружена.


Практические ограничения архитектуры

Использование debounce/throttle с YupResolver требует учитывать фундаментальное ограничение: resolver не предназначен для асинхронного управления потоком событий. Он рассчитан на детерминированный вызов при изменении состояния формы.

Любая попытка внедрить временную логику в resolver приводит к:

  • усложнению предсказуемости формы
  • усложнению тестирования
  • необходимости ручного управления состоянием ошибок

Поэтому наиболее устойчивые архитектуры переносят временную оптимизацию либо на уровень UI, либо на уровень внешних эффектов, оставляя YupResolver чистым вычислительным слоем.