Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Маскированный ввод (Input Masks) в React и TypeScript: проектирование типобезопасных полей

Тимофей Ищенко

Коротко: Практическое руководство по созданию типобезопасных масок ввода в React и TypeScript: raw value, управление кареткой, UI-kit, React Hook Form и Zod.

Маскированный ввод — одна из тех задач фронтенда, которая кажется тривиальной ровно до первого релиза в продакшен. На практике разработчики сталкиваются с «прыгающим» курсором при редактировании середины строки, некорректной вставкой из буфера обмена (clipboard paste), конфликтами с мобильными клавиатурами и засорением бизнес-логики лишними символами форматирования вроде пробелов, скобок и дефисов.

Построение надежного компонента маскированного ввода требует строгого разделения сырых и форматированных данных, продуманного управления кареткой и типобезопасности на уровне TypeScript.


Анатомия маскированного инпута: Raw Value vs Formatted Value

Главный источник технического долга при работе с масками — смешивание двух сущностей:

  1. Formatted Value (отображаемое значение) — строка, дополненная служебными символами для удобства восприятия пользователем (например, +7 (999) 123-45-67 или 12/28).
  2. 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).

Правильный паттерн:

  1. Позволить движку маски перехватывать событие beforeinput или input.
  2. Вычислять новое положение каретки с учетом добавленных или удаленных служебных символов.
  3. Передавать наружу нормализованное значение без принудительного сброса фокуса.

Интеграция с 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

  1. Разделение состояния: Хранилище формы получает только rawValue, маска отображается исключительно в UI.
  2. Типобезопасность: Пропсы компонента строго типизированы, исключена передача произвольных невалидных строк в конфигурацию маски.
  3. Обработка буфера обмена: Вставка строк с пробелами, дефисами и скобками корректно парсится без обрезания данных.
  4. Мобильные ОС: Проверена работа предиктивного набора (autocomplete/autofill) на iOS Safari и Android Chrome.
  5. Атрибуты ввода: Выставлен корректный inputMode и семантический autoComplete (tel, cc-number, bday).
  6. Доступность: Инпут имеет связанный лейбл и текстовые подсказки об ошибках с role="alert".
  7. Серверная валидация: Клиентская маска подкреплена независимой валидацией данных на стороне 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 на любых типах устройств.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также