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

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

Контролируемые vs Неконтролируемые компоненты в дизайн-системах: архитектурные паттерны и практика

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

Коротко: Разбираем контролируемые и неконтролируемые компоненты в React: гибридный паттерн, хук useControllableState, типизация на TypeScript и интеграция в UI-kit.

При проектировании переиспользуемого UI-kit разработчики часто сталкиваются с дилеммой: сделать компоненты строго контролируемыми (controlled) или отдать управление внутреннему состоянию и DOM (uncontrolled). Жесткий выбор в пользу только одного подхода неизбежно бьет по удобству разработки (DX): избыточный контроль заставляет писать десятки лишних строк бойлерплейта на простых формах, а исключительно неконтролируемые компоненты лишают гибкости при построении сложной бизнес-логики.

Библиотеки уровня Radix UI, Base UI или Chakra UI давно решили эту проблему гибридным подходом. Разберем механику каждого паттерна, типовые архитектурные ошибки и напишем гибкий кастомный хук для одновременной поддержки обоих режимов со строгой типизацией на TypeScript.


Фундаментальная разница: как React управляет состоянием

Разница между контролируемыми и неконтролируемыми компонентами сводится к тому, где находится источник истины (Single Source of Truth) — в React-состоянии приложения или внутри самого DOM-узла / локального состояния инкапсулированного компонента.

+-----------------------------------------------------------------+
|                      Контролируемый подход                      |
|                                                                 |
|   Parent Component [State]  ---( value )--->  UI-Kit Component  |
|          ^                                           |          |
|          +------------( onChange(newValue) )--------+          |
+-----------------------------------------------------------------+

+-----------------------------------------------------------------+
|                     Неконтролируемый подход                     |
|                                                                 |
|   Parent Component          ---( defaultValue )-> UI-Kit Comp   |
|         |                                         [DOM / State] |
|   ( submit / ref.current ) <-----------------------------+      |
+-----------------------------------------------------------------+

Контролируемый подход

В контролируемом компоненте данные целиком управляются внешним кодом через props (обычно пара value и onChange). Компонент является «чистым» отображением входящих данных:

const [email, setEmail] = useState('');

<Input 
  value={email} 
  onChange={(e) => setEmail(e.target.value)} 
/>

Преимущества:

  • Полная предсказуемость: родительский компонент в любой момент знает точное значение и может мгновенно реагировать на ввод.
  • Синхронная валидация и маскирование: легко блокировать ввод недопустимых символов или форматировать номер телефона на лету.
  • Управление связанными полями: изменение одного поля может сразу модифицировать значения других контролов.

Недостатки:

  • Ререндеры: каждый введенный символ вызывает ререндер родительского компонента со всем его поддеревом (если не выстроена точечная мемоизация).
  • Бойлерплейт: для каждого поля требуется явное объявление состояния и обработчика.

Неконтролируемый подход

Неконтролируемый компонент хранит данные в собственном локальном стейте или полагается на нативное поведение DOM-дерева. Исходное значение задается через defaultValue, а актуальные данные считываются по требованию с помощью ref или нативного FormData при отправке формы:

const inputRef = useRef<HTMLInputElement>(null);

<Input 
  defaultValue="john@example.com" 
  ref={inputRef} 
/>

Преимущества:

  • Производительность: ввод текста изолирован внутри DOM, родительские компоненты не перерисовываются на каждый символ.
  • Простая интеграция с библиотеками форм: бесшовная связка с инструментами на базе подписок (например, React Hook Form).
  • Минимум кода: для простых статических форм не нужно создавать отдельные стейты под каждое поле.

Недостатки:

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

Ограничения крайних подходов в UI-Kit

Дизайн-система обслуживает десятки продуктовых команд с принципиально разными задачами. Односторонний выбор неизбежно приводит к архитектурным тупикам.

Проблема принудительного контроля

Если UI-kit предоставляет компоненты вроде Select, Tabs или Accordion исключительно в контролируемом виде, разработчики вынуждены создавать промежуточные стейты даже для простейших статичных интерфейсов:

// Неудобно: библиотека заставляет контролировать табы там, где это не требуется
const [activeTab, setActiveTab] = useState('profile');

<Tabs value={activeTab} onChange={setActiveTab}>
  <Tab id="profile">Профиль</Tab>
  <Tab id="settings">Настройки</Tab>
</Tabs>

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

Ошибка переключения режимов (uncontrolled to controlled)

Распространенная ошибка при разработке библиотечных компонентов — передача undefined в качестве начального value:

// Ошибка в родительском коде или внутри UI-компонента
const [user, setUser] = useState<{ name?: string }>({});

// На первом рендере user.name равен undefined -> инпут инициализируется как uncontrolled.
// После загрузки данных user.name становится строкой -> инпут переходит в controlled.
<Input value={user.name} onChange={(e) => setUser({ name: e.target.value })} />

React отреагирует предупреждением в консоли:
Warning: A component is changing an uncontrolled input to be controlled.

Чтобы этого избежать, компонент должен либо нормализовать value (например, приводя к пустой строке через value ?? ''), либо явно разграничивать режимы работы внутри своей архитектуры.


Гибридный паттерн: хук useControllableState

Индустриальный стандарт для дизайн-систем — гибридная модель. Компонент проверяет наличие внешнего пропса value. Если он передан — компонент работает в контролируемом режиме, если нет — переключается на внутренний useState, инициализированный через defaultValue.

Реализуем кастомный хук useControllableState:

import { useState, useCallback, useRef, useEffect } from 'react';

interface UseControllableStateProps<T> {
  value?: T;
  defaultValue?: T | (() => T);
  onChange?: (value: T) => void;
}

export function useControllableState<T>({
  value: propValue,
  defaultValue,
  onChange,
}: UseControllableStateProps<T>): [T, (next: T | ((prev: T) => T)) => void] {
  const isControlled = propValue !== undefined;
  const isControlledRef = useRef(isControlled);

  useEffect(() => {
    isControlledRef.current = isControlled;
  }, [isControlled]);

  const [uncontrolledValue, setUncontrolledValue] = useState<T>(() => {
    if (defaultValue !== undefined) {
      return typeof defaultValue === 'function' 
        ? (defaultValue as () => T)() 
        : defaultValue;
    }
    return undefined as unknown as T;
  });

  const currentValue = isControlled ? (propValue as T) : uncontrolledValue;

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const nextValue = typeof next === 'function'
        ? (next as (prev: T) => T)(currentValue)
        : next;

      if (!isControlledRef.current) {
        setUncontrolledValue(nextValue);
      }

      onChange?.(nextValue);
    },
    [currentValue, onChange]
  );

  return [currentValue, setValue];
}

Применение хука в компоненте Switch (Toggle)

import React, { forwardRef } from 'react';
import { useControllableState } from './useControllableState';

export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value' | 'defaultValue'> {
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?: (checked: boolean) => void;
}

export const Switch = forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
  const {
    checked: checkedProp,
    defaultChecked = false,
    onChange,
    disabled,
    className,
    ...restProps
  } = props;

  const [checked, setChecked] = useControllableState({
    value: checkedProp,
    defaultValue: defaultChecked,
    onChange,
  });

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (disabled) return;
    setChecked(!checked);
    restProps.onClick?.(e);
  };

  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      disabled={disabled}
      onClick={handleClick}
      ref={ref}
      className={`switch ${checked ? 'switch--checked' : ''} ${className ?? ''}`}
      {...restProps}
    >
      <span className="switch__thumb" />
    </button>
  );
});

Switch.displayName = 'Switch';

Теперь Switch одинаково предсказуемо ведет себя в обоих сценариях:

// 1. Неконтролируемый режим (простой UI без внешнего стейта)
<Switch defaultChecked={true} onChange={(val) => console.log('Toggled:', val)} />

// 2. Контролируемый режим (зависимости, внешняя бизнес-логика)
const [notifications, setNotifications] = useState(false);
<Switch checked={notifications} onChange={setNotifications} />

Строгая типизация API через TypeScript

С помощью TypeScript можно на уровне компиляции предотвратить передачу некорректных комбинаций параметров — например, передачу value без обязательного обработчика onChange, если дизайн-система требует строгого контракта.

Для этого применяются дискриминированные объединения (Discriminated Unions):

type BaseSelectProps<T> = {
  options: Array<{ label: string; value: T }>;
  disabled?: boolean;
};

type ControlledSelectProps<T> = BaseSelectProps<T> & {
  value: T;
  onChange: (value: T) => void;
  defaultValue?: never;
};

type UncontrolledSelectProps<T> = BaseSelectProps<T> & {
  value?: never;
  onChange?: (value: T) => void;
  defaultValue?: T;
};

export type SelectProps<T> = ControlledSelectProps<T> | UncontrolledSelectProps<T>;

При такой типизации одновременная передача value и defaultValue вызовет ошибку еще на этапе сборки:

// Ошибка компиляции: Type 'string' is not assignable to type 'undefined'
<Select 
  options={items} 
  value={currentValue} 
  defaultValue="default" 
  onChange={handleChange} 
/>

Контролируемость за пределами текстовых полей

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

  1. Модальные окна и оверлеи: open / defaultOpen, onOpenChange.
  2. Аккордеоны и раскрывающиеся списки: expandedIds / defaultExpandedIds.
  3. Табы: activeTab / defaultActiveTab.
  4. Пагинация: currentPage / defaultPage.
// Неконтролируемый Dropdown — сам управляет открытием и закрытием
<DropdownMenu defaultOpen={false}>
  <DropdownMenu.Trigger>Действия</DropdownMenu.Trigger>
  <DropdownMenu.Content>
    <DropdownMenu.Item onSelect={handleEdit}>Редактировать</DropdownMenu.Item>
  </DropdownMenu.Content>
</DropdownMenu>

// Контролируемый Dropdown — открытие подчиняется внешнему стейту
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
  <DropdownMenu.Trigger>Действия</DropdownMenu.Trigger>
  <DropdownMenu.Content>...</DropdownMenu.Content>
</DropdownMenu>

Нюанс с анимациями закрытия

В модальных окнах или выпадающих списках при контролируемом закрытии (open={false}) родительский компонент может мгновенно размонтировать узел из дерева. Если в UI-kit заложены CSS-анимации выхода (exit transitions), компонент должен иметь внутренний статус жизненного цикла (mounted/unmounted), удерживающий узел в DOM до завершения анимации, даже когда внешний флаг open уже перешел в false.


Интеграция с библиотеками форм (React Hook Form)

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

Чтобы нативный компонент дизайн-системы бесшовно подключался к React Hook Form через метод register, он обязан:

  1. Корректно пробрасывать ref на внутренний DOM-элемент через forwardRef.
  2. Принимать стандартные атрибуты name, onBlur, onChange.
// Интеграция через ref (максимальная производительность)
const { register, handleSubmit } = useForm<FormData>();

<Input 
  {...register('username', { required: true })} 
  placeholder="Введите логин" 
/>

Для сложных составных компонентов (кастомный DatePicker или MultiSelect без нативного инпута) гибридная архитектура позволяет использовать обертку Controller:

<Controller
  name="birthDate"
  control={control}
  render={({ field }) => (
    <DatePicker
      value={field.value}
      onChange={field.onChange}
      ref={field.ref}
    />
  )}
/>

Сравнительный чеклист проектирования компонентов

Компонент Рекомендуемый дефолтный режим Критические свойства API Основные риски
Input / Textarea Uncontrolled (с поддержкой Controlled) value, defaultValue, ref, onChange Потеря фокуса, задержки ввода при ререндере
Checkbox / Switch Гибридный checked, defaultChecked, onChange Рассинхронизация с нативным DOM-состоянием
Select / Combobox Гибридный value, defaultValue, onValueChange Сложность позиционирования и фокуса клавиатуры
Modal / Dialog Гибридный open, defaultOpen, onOpenChange Преждевременный демонтаж DOM до завершения анимации
Tabs Uncontrolled (с поддержкой Controlled) value, defaultValue, onValueChange Избыточный ререндер контента неактивных вкладок

Часто задаваемые вопросы (FAQ)

1. Можно ли делать компоненты в дизайн-системе строго контролируемыми?

Делать базовые компоненты (Input, Checkbox, Select) строго контролируемыми не рекомендуется. Это превращает дизайн-систему в источник избыточного бойлерплейта и создает барьеры при интеграции с инструментами, ориентированными на неконтролируемые поля.

2. Почему React предупреждает об изменении с uncontrolled на controlled?

Это происходит, когда значение value изначально передается как undefined, а позже обновляется строкой или числом (например, после завершения запроса к API). Для исправления передавайте явный fallback (value={user.name ?? ''}) либо инициализируйте компонент через defaultValue.

3. Зачем прокидывать ref через forwardRef для всех компонентов дизайн-системы?

forwardRef предоставляет прямое управление DOM-узлом. Это необходимо для библиотек форм, программной установки фокуса при валидации, расчетных систем позиционирования всплывающих окон и соблюдения стандартов доступности (WAI-ARIA).

4. Влияет ли хук useControllableState на производительность?

В контролируемом режиме хук передает вызовы onChange наружу и не инициирует обновление собственного стейта, предотвращая лишние циклы отрисовки. В неконтролируемом режиме состояние изолируется внутри самого компонента, полностью избавляя дерево предков от перерисовок.

5. Как обрабатывать сброс формы (form reset) в неконтролируемых компонентах?

Стандартные HTML-элементы сбрасываются браузером автоматически при событии reset на теге <form>. Для кастомных компонентов внутри гибридного хука можно слушать событие сброса родительской формы через ref и программно возвращать локальное состояние к значению defaultValue.


Резюме

Проектирование компонентов дизайн-системы требует взвешенного баланса между автономностью и возможностью централизованного управления. Принуждение к одной модели поведения ограничивает сценарии использования UI-kit.

Внедрение гибких контрактов с поддержкой парных пропсов (value / defaultValue, open / defaultOpen) и централизация этой логики в хуке useControllableState закрывает все продуктовые требования: от легковесных и быстрых форм до комплексных интерфейсов со сложной зависимой валидацией.

Источники

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

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