Кастомные компоненты формы

16 июня 2026
Автор

Олег Марков

Кастомные компоненты формы в React

Введение

По мере роста приложения вы начинаете замечать дублирование кода форм: одни и те же стили, логика отображения ошибок, лейблы и подсказки повторяются в каждом компоненте. Решение — создать библиотеку переиспользуемых компонентов форм.

Кастомные компоненты форм позволяют:

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

В этой статье мы создадим полноценную систему переиспользуемых компонентов форм с поддержкой React Hook Form.

Базовый компонент TextInput

Начнём с простой обёртки над <input>:

import React from 'react';

interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
}

export function TextInput({
  label,
  error,
  hint,
  required,
  id,
  className,
  ...rest
}: TextInputProps) {
  const inputId = id || `input-${label.toLowerCase().replace(/\s+/g, '-')}`;

  return (
    <div className={`form-field ${className || ''}`}>
      <label htmlFor={inputId} className="form-label">
        {label}
        {required && <span aria-hidden="true" className="required-mark"> *</span>}
      </label>

      <input
        id={inputId}
        className={`form-input ${error ? 'form-input--error' : ''}`}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={
          [hint && `${inputId}-hint`, error && `${inputId}-error`]
            .filter(Boolean)
            .join(' ') || undefined
        }
        {...rest}
      />

      {hint && !error && (
        <p id={`${inputId}-hint`} className="form-hint">
          {hint}
        </p>
      )}

      {error && (
        <p id={`${inputId}-error`} className="form-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

// Использование
function SignupForm() {
  return (
    <form>
      <TextInput
        label="Имя"
        name="name"
        placeholder="Иван"
        required
        error="Имя обязательно"
        hint="Введите своё настоящее имя"
      />
      <TextInput
        label="Email"
        name="email"
        type="email"
        required
      />
    </form>
  );
}

forwardRef для интеграции с библиотеками

Чтобы компонент работал с React Hook Form, Formik и другими библиотеками, нужен forwardRef — он позволяет внешнему коду получить прямой доступ к DOM-элементу:

import React, { forwardRef } from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  hint?: string;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, id, ...rest }, ref) => {
    const inputId = id || `input-${Math.random().toString(36).slice(2)}`;

    return (
      <div className="form-field">
        <label htmlFor={inputId}>{label}</label>
        <input
          id={inputId}
          ref={ref}     // Передаём ref в DOM-элемент
          aria-invalid={!!error}
          {...rest}
        />
        {hint && <p className="hint">{hint}</p>}
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

Input.displayName = 'Input';

Компонент Select

import { forwardRef } from 'react';

interface SelectOption {
  value: string;
  label: string;
}

interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
  label: string;
  options: SelectOption[];
  error?: string;
  placeholder?: string;
}

export const Select = forwardRef<HTMLSelectElement, SelectProps>(
  ({ label, options, error, placeholder, id, ...rest }, ref) => {
    const selectId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;

    return (
      <div className="form-field">
        <label htmlFor={selectId}>{label}</label>
        <select
          id={selectId}
          ref={ref}
          aria-invalid={!!error}
          {...rest}
        >
          {placeholder && (
            <option value="" disabled>
              {placeholder}
            </option>
          )}
          {options.map(({ value, label }) => (
            <option key={value} value={value}>
              {label}
            </option>
          ))}
        </select>
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

Select.displayName = 'Select';

// Использование
<Select
  label="Страна"
  options={[
    { value: 'ru', label: 'Россия' },
    { value: 'by', label: 'Беларусь' },
    { value: 'kz', label: 'Казахстан' },
  ]}
  placeholder="Выберите страну"
  error="Выберите страну"
/>

Компонент Checkbox

import { forwardRef } from 'react';

interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  description?: string;
}

export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  ({ label, error, description, id, ...rest }, ref) => {
    const checkboxId = id || `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;

    return (
      <div className="form-checkbox">
        <div className="checkbox-wrapper">
          <input
            type="checkbox"
            id={checkboxId}
            ref={ref}
            aria-invalid={!!error}
            {...rest}
          />
          <label htmlFor={checkboxId}>{label}</label>
        </div>
        {description && <p className="checkbox-description">{description}</p>}
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

Checkbox.displayName = 'Checkbox';

Группа радиокнопок

import { forwardRef } from 'react';

interface RadioOption {
  value: string;
  label: string;
  description?: string;
}

interface RadioGroupProps {
  name: string;
  label: string;
  options: RadioOption[];
  value?: string;
  onChange?: (value: string) => void;
  error?: string;
}

export function RadioGroup({
  name,
  label,
  options,
  value,
  onChange,
  error,
}: RadioGroupProps) {
  return (
    <fieldset className="form-radio-group">
      <legend className="radio-group-label">{label}</legend>
      {options.map((option) => (
        <label key={option.value} className="radio-option">
          <input
            type="radio"
            name={name}
            value={option.value}
            checked={value === option.value}
            onChange={() => onChange?.(option.value)}
          />
          <span className="radio-label">{option.label}</span>
          {option.description && (
            <span className="radio-description">{option.description}</span>
          )}
        </label>
      ))}
      {error && <p className="error">{error}</p>}
    </fieldset>
  );
}

// Использование
<RadioGroup
  name="plan"
  label="Тарифный план"
  options={[
    { value: 'free', label: 'Бесплатный', description: 'До 5 проектов' },
    { value: 'pro', label: 'Pro', description: 'Неограниченно' },
    { value: 'enterprise', label: 'Enterprise', description: 'Корпоративное решение' },
  ]}
  value={plan}
  onChange={setPlan}
/>

Компонент Textarea

import { forwardRef } from 'react';

interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
  label: string;
  error?: string;
  hint?: string;
  showCharCount?: boolean;
}

export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ label, error, hint, showCharCount, maxLength, value, id, ...rest }, ref) => {
    const textareaId = id || `textarea-${label.toLowerCase().replace(/\s+/g, '-')}`;
    const charCount = typeof value === 'string' ? value.length : 0;

    return (
      <div className="form-field">
        <div className="field-header">
          <label htmlFor={textareaId}>{label}</label>
          {showCharCount && maxLength && (
            <span className={`char-count ${charCount > maxLength * 0.9 ? 'char-count--warning' : ''}`}>
              {charCount}/{maxLength}
            </span>
          )}
        </div>
        <textarea
          id={textareaId}
          ref={ref}
          maxLength={maxLength}
          value={value}
          aria-invalid={!!error}
          {...rest}
        />
        {hint && <p className="hint">{hint}</p>}
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

Textarea.displayName = 'Textarea';

Кастомный DatePicker

Создание кастомного выбора даты:

import { useState } from 'react';

interface DatePickerProps {
  label: string;
  value?: string; // YYYY-MM-DD
  onChange?: (date: string) => void;
  error?: string;
  min?: string;
  max?: string;
}

export function DatePicker({ label, value, onChange, error, min, max }: DatePickerProps) {
  const [focused, setFocused] = useState(false);

  return (
    <div className={`date-picker ${focused ? 'date-picker--focused' : ''}`}>
      <label>{label}</label>
      <div className="date-picker-wrapper">
        <input
          type="date"
          value={value || ''}
          onChange={(e) => onChange?.(e.target.value)}
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
          min={min}
          max={max}
          aria-invalid={!!error}
        />
        <span className="calendar-icon" aria-hidden="true">📅</span>
      </div>
      {error && <p className="error">{error}</p>}
    </div>
  );
}

Интеграция с React Hook Form через Controller

Для кастомных компонентов, которые нельзя просто «прокинуть» через ref, используем Controller:

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2, 'Минимум 2 символа'),
  email: z.string().email('Некорректный email'),
  country: z.string().min(1, 'Выберите страну'),
  plan: z.enum(['free', 'pro', 'enterprise']),
  agreeToTerms: z.literal(true, {
    errorMap: () => ({ message: 'Необходимо принять условия' }),
  }),
  bio: z.string().max(500, 'Максимум 500 символов').optional(),
});

type FormData = z.infer<typeof schema>;

function RegistrationForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log('Данные:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Компоненты с forwardRef — используем register напрямую */}
      <Input
        label="Имя"
        error={errors.name?.message}
        required
        {...register('name')}
      />

      <Input
        label="Email"
        type="email"
        error={errors.email?.message}
        required
        {...register('email')}
      />

      {/* Select с forwardRef */}
      <Select
        label="Страна"
        options={[
          { value: 'ru', label: 'Россия' },
          { value: 'by', label: 'Беларусь' },
        ]}
        placeholder="Выберите страну"
        error={errors.country?.message}
        {...register('country')}
      />

      {/* RadioGroup через Controller (нет forwardRef) */}
      <Controller
        name="plan"
        control={control}
        render={({ field }) => (
          <RadioGroup
            name="plan"
            label="Тарифный план"
            options={[
              { value: 'free', label: 'Бесплатный' },
              { value: 'pro', label: 'Pro' },
              { value: 'enterprise', label: 'Enterprise' },
            ]}
            value={field.value}
            onChange={field.onChange}
            error={errors.plan?.message}
          />
        )}
      />

      {/* Textarea с forwardRef */}
      <Textarea
        label="О себе"
        maxLength={500}
        showCharCount
        error={errors.bio?.message}
        {...register('bio')}
      />

      {/* Checkbox с forwardRef */}
      <Checkbox
        label="Принимаю условия использования"
        error={errors.agreeToTerms?.message}
        {...register('agreeToTerms')}
      />

      <button type="submit">Зарегистрироваться</button>
    </form>
  );
}

Составной компонент FormField

Паттерн Compound Components позволяет создать гибкий FormField:

import React, { createContext, useContext } from 'react';

interface FormFieldContextValue {
  id: string;
  error?: string;
}

const FormFieldContext = createContext<FormFieldContextValue>({ id: '' });

interface FormFieldProps {
  children: React.ReactNode;
  id?: string;
  error?: string;
}

// Корневой компонент
function FormField({ children, id = `field-${Math.random().toString(36).slice(2)}`, error }: FormFieldProps) {
  return (
    <FormFieldContext.Provider value={{ id, error }}>
      <div className={`form-field ${error ? 'form-field--error' : ''}`}>
        {children}
      </div>
    </FormFieldContext.Provider>
  );
}

// Подкомпоненты
FormField.Label = function FormFieldLabel({ children }: { children: React.ReactNode }) {
  const { id } = useContext(FormFieldContext);
  return <label htmlFor={id} className="form-label">{children}</label>;
};

FormField.Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
  (props, ref) => {
    const { id, error } = useContext(FormFieldContext);
    return (
      <input
        id={id}
        ref={ref}
        aria-invalid={!!error}
        className={`form-input ${error ? 'form-input--error' : ''}`}
        {...props}
      />
    );
  }
);
FormField.Input.displayName = 'FormField.Input';

FormField.Error = function FormFieldError() {
  const { id, error } = useContext(FormFieldContext);
  if (!error) return null;
  return <p id={`${id}-error`} className="form-error" role="alert">{error}</p>;
};

FormField.Hint = function FormFieldHint({ children }: { children: React.ReactNode }) {
  const { id } = useContext(FormFieldContext);
  return <p id={`${id}-hint`} className="form-hint">{children}</p>;
};

// Использование составного компонента
function FlexibleForm() {
  return (
    <form>
      <FormField error="Email обязателен">
        <FormField.Label>Email *</FormField.Label>
        <FormField.Hint>Мы никогда не передаём ваш email третьим лицам</FormField.Hint>
        <FormField.Input type="email" placeholder="user@example.com" />
        <FormField.Error />
      </FormField>
    </form>
  );
}

Компонент PasswordInput со скрытием/показом пароля

import { useState, forwardRef } from 'react';

interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
  label: string;
  error?: string;
}

export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
  ({ label, error, id, ...rest }, ref) => {
    const [showPassword, setShowPassword] = useState(false);
    const inputId = id || 'password-input';

    return (
      <div className="form-field password-field">
        <label htmlFor={inputId}>{label}</label>
        <div className="password-wrapper">
          <input
            id={inputId}
            ref={ref}
            type={showPassword ? 'text' : 'password'}
            aria-invalid={!!error}
            {...rest}
          />
          <button
            type="button"
            className="password-toggle"
            onClick={() => setShowPassword((v) => !v)}
            aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
          >
            {showPassword ? '🙈' : '👁️'}
          </button>
        </div>
        {error && <p className="error">{error}</p>}
      </div>
    );
  }
);

PasswordInput.displayName = 'PasswordInput';

Индикатор надёжности пароля

function PasswordStrengthIndicator({ password }: { password: string }) {
  const getStrength = (pwd: string) => {
    let score = 0;
    if (pwd.length >= 8) score++;
    if (pwd.length >= 12) score++;
    if (/[A-Z]/.test(pwd)) score++;
    if (/[0-9]/.test(pwd)) score++;
    if (/[^A-Za-z0-9]/.test(pwd)) score++;
    return score;
  };

  const strength = getStrength(password);

  const levels = [
    { label: '', color: 'transparent' },
    { label: 'Очень слабый', color: '#ef4444' },
    { label: 'Слабый', color: '#f97316' },
    { label: 'Средний', color: '#eab308' },
    { label: 'Сильный', color: '#22c55e' },
    { label: 'Очень сильный', color: '#16a34a' },
  ];

  if (!password) return null;

  return (
    <div className="password-strength">
      <div className="strength-bars">
        {[1, 2, 3, 4, 5].map((level) => (
          <div
            key={level}
            className="strength-bar"
            style={{
              backgroundColor: level <= strength ? levels[strength].color : '#e5e7eb',
            }}
          />
        ))}
      </div>
      <span style={{ color: levels[strength].color }}>
        {levels[strength].label}
      </span>
    </div>
  );
}

// Используем совместно с PasswordInput
function SecurePasswordForm() {
  const { register, watch } = useForm();
  const password = watch('password', '');

  return (
    <form>
      <PasswordInput
        label="Пароль"
        {...register('password')}
      />
      <PasswordStrengthIndicator password={password} />
    </form>
  );
}

Заключение

Создание переиспользуемых компонентов форм — это инвестиция, которая окупается по мере роста приложения. Ключевые принципы:

  • forwardRef — обязателен для компонентов, которые должны работать с ref (React Hook Form, Formik)
  • Проброс пропсов через ...rest — делает компоненты гибкими и не ограничивает их использование
  • Доступность — всегда добавляйте htmlFor/id, aria-invalid, aria-describedby
  • Единый контракт ошибок — стандартный проп error?: string для отображения ошибок
  • Controller для кастомных компонентов — когда forwardRef недостаточно или компонент имеет нестандартный интерфейс

Начните с простых компонентов (Input, Select, Checkbox) и постепенно добавляйте специализированные (DatePicker, PasswordInput, RadioGroup) по мере необходимости.

Стрелочка влевоМаски вводаКонтролируемые формыСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

Uncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыHOC в React: мастерство композиции компонентовuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRedux Toolkit - современный ReduxRecoil — библиотека управления состоянием от FacebookВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseOptimistic — оптимистичные обновления UIuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияuseEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19Оптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextКак предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в ReactFragment в React: группировка элементов без лишних узлов DOM
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий