Недавно, работая с компонентом ввода номера телефона в форме регистрации, я столкнулся с весьма неочевидной особенностью работы различных обработчиков событий. Связано это непосредственно с onChange, onPaste и onInput. Мне пришлось провести достаточно глубокий ресерч, чтобы разобраться в особенностях, которые я встретил. Начнем по порядку.

Кейсы пользовательского взаимодействия

Для начала разберемся, что же это был за инпут (потому что для обычного использования инпута в базовых ситуациях и видах эта статья вряд ли будет полезна). Инпут был приблизительно следующего формата:

Пример для инпута
Пример для инпута

Особенности инпута:

  1. Вводить можно только номер телефона

  2. Используется маскирование

  3. Код страны выбирается с помощью селекта слева и недоступен для пользовательского редактирования

  4. При попытке ввода символов необходимо попытаться определить, к какой стране относится номер

  5. При попытке вставки из буфера обмена устройства также необходимо попытаться определить страну и обрезать ненужные символы (+, код страны и все остальное кроме цифр)

  6. Необходимо поддержать возможность вставки из автозаполнения (браузерных садджестов). Самое важное, почему и пишется теперь эта статья.

Проблема

Небольшой гуглеж позволил мне понять, что правильно обработать данные из автозаполнения дает возможность обработчик onInput. Как ни странно, onPaste нам не подойдет, потому что это лишь вставка из буфера обмена (да, они отличаются), а onChange взаимодействует лишь с вводом с клавиатуры.

Добавив событие onInput, прокинув туда логику по определению страны и обрезке ненужных символов и протестировав в одном из браузеров, я довольный пошел создавать ПР c решенной задачей в одну строчку. Как бы ни так, в Google Chrome, как оказалось, это не срабатывает. Вставка происходит, однако ненужные символы не обрезаются. Я начал копать причину.

Решение

Различие между onChange и onInput в React

Как оказалось, даже в документации к React и в нескольких issues на гитхабе репозитория самого React, говорится, что onInput:

  • более предпочтителен

  • вызывает после себя onChange

Вот в чем проблема. Моя логика внутри onInput срабатывала. Только после этого вызывался onChange и делал обычную вставку текста из садджеста автозаполнения. Накинем немного теории чисто для контекста:

onChange в React:

  • React обрабатывает событие onChange как обертку над нативным событием input. Оно срабатывает каждый раз, когда значение поля ввода изменяется, включая ввод с клавиатуры, вставку текста (из буфера обмена или автозаполнения), а также изменения через скрипты.

  • Отличие от нативного события change в браузере заключается в том, что нативное событие change срабатывает только при потере фокуса (blur), а React-обработчик onChange срабатывает сразу после изменения значения.

onInput в React:

  • onInput в React напрямую связано с нативным событием input. Оно срабатывает при любом изменении значения, включая ввод текста, вставку, удаление, а также автозаполнение.

  • В отличие от onChange, onInput фокусируется только на самом событии ввода и не добавляет дополнительных абстракций.

Почему onInput вызывает onChange?

Это происходит из-за особенностей работы React. onChange в React фактически оборачивает событие onInput для обеспечения кроссбраузерной совместимости. Таким образом, любое срабатывание onInput автоматически вызывает onChange. Это не баг, а намеренное поведение React.

Проблема с UX при автозаполнении номера телефона

Когда пользователь вставляет номер телефона через автозаполнение, onInput действительно срабатывает, а затем триггерит onChange. Если попытаться обработать обрезку в onInput, то onChange может переписать результат.

Я попытался избавиться от этого самым очевидным способом:

queueMicrotask(() => trimPastedValue(e.target.value));

Это само собой сработало, так как теперь вставка обрабатывалась как микротаска, а значит сначала сработает onInput, а потом моя логика вставки из садджеста. Однако, я получил неожиданный эффект (хотя это весьма логично). Поскольку onInput - аналог onChange с некоторыми доработками, то эта же логика вызывалась для обычного ввода с клавиатуры. А так как это теперь микротаска, я не мог нормально ввести некоторые комбинации цифр, которые расчитывались логикой как то, что надо отрезать.

Разделение событий обычного ввода и автозаполнения

Стоит принять тот факт, что придется отказаться от обработчика onChange. Да, совсем. Оставив только onInput, мы сможем определить, каким ивентом является событие, которое нам приходит. Как ни странно, в моей конфигурации я добился этого с помощью следующей структуры:

const handlePhoneChange = (e: InputOnInputEvent) => {
  if (e.nativeEvent.inputType) { // проверяем только наличие поля
    setPhoneNumber(e.target.value); // обычный ввод с клавиатуры
    return;
  }

  pasteValue(e.target.value); // вставка с обрезкой и определением страны
};

Хотя ресерч предлагал мне проверять тот же inputType на определенные значения. Что-то вроде этого:

if (inputType === "insertFromPaste" || inputType === "insertFromAutoComplete") {
  pasteValue(e.target.value);
}

Однако, я не увидел, чтобы при попытке вставки из буфера обмена или автозаполнении в ивент передавался какой-либо inputType. Этого поля вообще не было в event.

Выводы

Если вы предполагаете, что в вашем проекте потенциально могут быть использованы инпуты со сложными конфигурациями (маски, обрезка каких-то символов руками и т.д.), то при создании переиспользуемого компонента инпута я настоятельно рекомендую завязываться только на onInput и не использовать onChange. Оставляя только onChange, вы рискуете столкнуться с тем же, что и я и вам придется перелопачивать часть логики попутно разбираясь, почему же эта ерунда не работает так, как нужно. Если вам нужно полностью исключить влияние onChange, используйте только onInput с продуманной логикой обработки. Важно помнить, что onChange всегда сработает после onInput.

Мой блог в ТГ

Комментарии (0)