Коротко: Практическое руководство по созданию типобезопасных масок ввода в React и TypeScript: raw value, управление кареткой, UI-kit, React Hook Form и Zod.
Маскированный ввод — одна из тех задач фронтенда, которая кажется тривиальной ровно до первого релиза в продакшен. На практике разработчики сталкиваются с «прыгающим» курсором при редактировании середины строки, некорректной вставкой из буфера обмена (clipboard paste), конфликтами с мобильными клавиатурами и засорением бизнес-логики лишними символами форматирования вроде пробелов, скобок и дефисов.
Построение надежного компонента маскированного ввода требует строгого разделения сырых и форматированных данных, продуманного управления кареткой и типобезопасности на уровне TypeScript.
Анатомия маскированного инпута: Raw Value vs Formatted Value
Главный источник технического долга при работе с масками — смешивание двух сущностей:
- Formatted Value (отображаемое значение) — строка, дополненная служебными символами для удобства восприятия пользователем (например,
+7 (999) 123-45-67или12/28). - Raw Value (сырое / unmasked-значение) — чистые данные без оформления, предназначенные для хранения в стейте, передачи в схемы валидации и отправки на бэкенд (например,
79991234567или1228).
Ввод пользователя: "9991234567"
│
▼
┌────────────────────────────────────────┐
│ Mask Engine (IMask / Maskito / custom) │
└────────────────────────────────────────┘
│ │
▼ ▼
[ Formatted Value ] [ Raw / Unmasked Value ]
"+7 (999) 123-45-67" "79991234567"
│ │
▼ ▼
HTML Input (UI) State / Form Engine / API
Если передавать в форму форматированное значение, логика приложения обрастает регулярными выражениями для очистки строк перед отправкой каждого запроса. Архитектурно правильный подход — изолировать маску внутри компонента ввода, отдавая наружу типизированное сырое значение через кастомный обработчик события или хук.
Обзор современных инструментов: от legacy к TypeScript-first
Экосистема JavaScript предлагает несколько поколений инструментов для маскирования:
1. Legacy-библиотеки: jQuery Inputmask, Cleave.js
- Особенности: Cleave.js долгое время был стандартом, но сейчас проект практически не развивается.
- Недостатки: Сложная интеграция с декларативным рендерингом React, трудности с кастомной типизацией и ощутимый вес бандла у старых решений.
2. react-input-mask
- Особенности: Популярный React-компонент со стандартными шаблонами (
9— цифра,a— буква,*— буквенно-цифровой символ). - Недостатки: Медленный цикл обновлений, проблемы с поддержкой современного конкурентного режима React и ограничения при работе со сложными динамическими масками (например, динамическая длина валюты).
3. IMask (react-imask)
- Особенности: Мощный движок с открытым исходным кодом, поддерживающий регулярные выражения, динамические диапазоны, даты, числа и блоки.
- Плюсы: Отличная производительность, отдельный хук
useIMask, зрелая экосистема.
4. Maskito
- Особенности: Современный фреймворк-агностичный инструмент, изначально спроектированный на TypeScript.
- Плюсы: Построен вокруг нативных событий браузера, нулевые зависимости, модульная архитектура (отдельные пакеты для телефонов, дат и чисел), полная поддержка предиктивного ввода на мобильных устройствах.
Проектирование типобезопасного компонента MaskedInput в UI-kit
При разработке переиспользуемого компонента в дизайн-системе важно предоставить строгие контракты типов для различных сценариев (телефон, дата, номер карты, кастомный шаблон).
Строгая типизация через Generic-контракты
Создадим компонент, строго разграничивающий типы масок и их параметры:
import React from 'react';
export type MaskKind = 'phone' | 'date' | 'card' | 'currency';
export interface MaskConfigMap {
phone: {
mask: '+{7} (000) 000-00-00';
lazy: boolean;
};
date: {
mask: Date;
pattern: 'd.`m.`Y';
};
card: {
mask: '0000 0000 0000 0000';
};
currency: {
mask: NumberConstructor;
scale: number;
thousandsSeparator: string;
};
}
export interface MaskedInputProps<K extends MaskKind>
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
kind: K;
value: string;
onValueChange: (rawValue: string, formattedValue: string) => void;
}
Использование Template Literal Types для компиляции шаблонов
TypeScript позволяет валидировать формат масок и значений еще на этапе сборки с помощью шаблонных литералов:
// Строгий тип для даты в формате DD.MM.YYYY
export type DateString = `${number}${number}.${number}${number}.${number}${number}${number}${number}`;
// Строгий тип для телефонного номера РФ
export type RuPhoneRaw = `7${number}`;
export function isValidRuPhone(val: string): val is RuPhoneRaw {
return /^7\d{10}$/.test(val);
}
Контролируемый ввод без рассинхронизации курсора
Ключевая проблема при реализации масок в React — попытка перезаписать event.target.value внутри стандартного onChange. Это сбивает внутреннее состояние каретки браузера (selectionStart и selectionEnd).
Правильный паттерн:
- Позволить движку маски перехватывать событие
beforeinputилиinput. - Вычислять новое положение каретки с учетом добавленных или удаленных служебных символов.
- Передавать наружу нормализованное значение без принудительного сброса фокуса.
Интеграция с React Hook Form и Zod
При интеграции с менеджерами форм рекомендуется сохранять в схему данных именно rawValue, накладывая на него валидацию Zod.
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { z } from 'zod';
import { IMaskInput } from 'react-imask';
// 1. Zod-схема работает с "сырыми" 11 цифрами номера РФ
const formSchema = z.object({
phone: z
.string()
.length(11, 'Номер телефона должен содержать 11 цифр')
.regex(/^7\d{10}$/, 'Некорректный формат номера'),
});
type FormData = z.infer<typeof formSchema>;
export const OrderForm: React.FC = () => {
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
defaultValues: {
phone: '',
},
});
const onSubmit = (data: FormData) => {
// В data.phone отправляется чистое значение: "79991234567"
fetch('/api/order', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="phone-input">Номер телефона</label>
<Controller
name="phone"
control={control}
render={({ field: { onChange, value, ref } }) => (
<IMaskInput
id="phone-input"
mask="+{7} (000) 000-00-00"
unmask={true} // Передавать unmasked value в onChange
value={value}
inputRef={ref}
onAccept={(unmasked) => onChange(unmasked)}
inputMode="tel"
/>
)}
/>
{errors.phone && <span role="alert">{errors.phone.message}</span>}
<button type="submit">Отправить</button>
</form>
);
};
Подводные камни UX и доступности (a11y)
Маскированный ввод часто создает барьеры для пользователей со скринридерами и мобильными устройствами, если проигнорированы базовые спецификации HTML и WAI-ARIA.
1. Настройка виртуальных клавиатур (inputMode)
Атрибут type="text" заставляет мобильный браузер открывать стандартную буквенную клавиатуру. Для масок обязательно выставляйте корректный inputMode:
- Телефон:
inputMode="tel" - Номера карт, СНИЛС, коды из SMS:
inputMode="numeric" - Суммы и дробные числа:
inputMode="decimal"
2. Скринридеры и служебные символы
Скринридеры (VoiceOver, NVDA) могут зачитывать маску буквально (например, «плюс семь скобка открывается девять девять девять...»). Чтобы улучшить доступность:
- Добавляйте понятный
aria-labelили связывайте инпут с<label>черезhtmlFor. - Используйте
aria-describedbyс подсказкой формата (например: «Формат: 10 цифр номера без восьмерки»). - Не блокируйте вставку из буфера обмена: обработчик paste должен очищать вставляемую строку от лишних символов и форматировать ее заново.
Чек-лист для внедрения маскированного ввода в production
- Разделение состояния: Хранилище формы получает только
rawValue, маска отображается исключительно в UI. - Типобезопасность: Пропсы компонента строго типизированы, исключена передача произвольных невалидных строк в конфигурацию маски.
- Обработка буфера обмена: Вставка строк с пробелами, дефисами и скобками корректно парсится без обрезания данных.
- Мобильные ОС: Проверена работа предиктивного набора (autocomplete/autofill) на iOS Safari и Android Chrome.
- Атрибуты ввода: Выставлен корректный
inputModeи семантическийautoComplete(tel,cc-number,bday). - Доступность: Инпут имеет связанный лейбл и текстовые подсказки об ошибках с
role="alert". - Серверная валидация: Клиентская маска подкреплена независимой валидацией данных на стороне API.
Часто задаваемые вопросы (FAQ)
Чем отличается маска ввода от клиентской валидации?
Маска контролирует и форматирует символы непосредственно в момент их ввода, запрещая ввод непредусмотренных знаков. Валидация проверяет конечное соответствие строки бизнес-правилам (например, корректность контрольной суммы номера карты по алгоритму Луна или существование даты) и сообщает об ошибке.
Что отправлять на сервер: форматированную строку или сырое значение?
В подавляющем большинстве случаев на бэкенд передается сырое значение (unmasked value). Это предотвращает рассинхронизацию форматов в базе данных и упрощает валидацию. Форматирование должно оставаться ответственностью слоя представления (UI).
Почему сбивается позиция курсора при вводе в середину маски?
Это происходит, если React перерисовывает компонент через стандартный onChange, перезаписывая input.value строкой новой длины без пересчета координат каретки. Для решения этой проблемы специализированные библиотеки (IMask, Maskito) вручную восстанавливают позицию selectionStart после модификации строки.
Как корректно обрабатывать автозаполнение браузера (Browser Autofill)?
Некоторые браузеры вставляют данные в поля ввода в обход стандартных событий клавиатуры. Чтобы маска не ломалась, библиотека должна слушать события change и input, а сам элемент должен иметь корректный атрибут autoComplete (например, autocomplete="cc-number" для банковских карт).
Стоит ли писать собственную реализацию маски с нуля?
Писать собственное решение с нуля оправдано только для простейших кейсов с фиксированной длиной строки. Полноценная поддержка каретки, вставки из буфера, выделения диапазонов текста, удаления через Backspace/Delete в середине слова и предиктивного ввода на мобильных устройствах требует сотен строк кода обработки граничных случаев. Для продакшена надежнее использовать зрелые решения вроде IMask или Maskito.
Заключение
Маскированный ввод — это не просто косметическое улучшение интерфейса, а полноценный компонент взаимодействия с пользователем, требующий строгой изоляции форматирования от бизнес-логики. Проектирование типобезопасных оберток над проверенными библиотеками позволяет сократить технический долг, гарантировать целостность данных в формах и обеспечить стабильный UX на любых типах устройств.




.svg.webp)





