open atlas
↑ К треку
Паттерны React RXP · 02 · 03

Управляемый против неуправляемого

Переиспользуемый виджет поддерживает оба режима — управляемый (состоянием владеет родитель через value/onChange) и неуправляемый (владеет компонент через defaultValue) — на одном хуке useControllableState, который предпочитает проп, если он задан, иначе берёт внутреннее.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ты собрал виджет Tabs для своего юнита по compound-компонентам. Первому экрану, который его использует, нужны просто работающие вкладки — кликнул по вкладке, она показалась. Второму экрану нужно, чтобы активная вкладка жила в URL, синхронизировалась с кнопкой «дальше» и сбрасывалась при смене фильтра. Один и тот же виджет, два совершенно разных отношения к состоянию. Если твой Tabs поддерживает лишь одно из них, половина его пользователей будет с ним воевать.

Это дуальность управляемого/неуправляемого — старейший паттерн переиспользуемого компонента в React, тот самый, на котором построен сам <input>. Senior-виджет предлагает оба режима из одной реализации, чтобы тривиальное применение оставалось тривиальным, а требовательное получало полный контроль. Ошибка здесь рождает одно из самых печально известных рантайм-предупреждений React.

Цель

После этого урока ты можешь объяснить разницу между управляемым компонентом (состоянием владеет родитель через value/onChange) и неуправляемым (состоянием владеет компонент через defaultValue); реализовать хук useControllableState, который использует проп, когда он задан, и внутреннее состояние иначе; спроектировать API compound-виджета так, чтобы простые вызовы оставались лаконичными, а сложные получали полный контроль; и назвать режим отказа — навязывание только-управляемого режима или тихая смена режима по ходу жизни (баг с предупреждением о переходе управляемого в неуправляемый).

1

Управляемый означает, что состоянием владеет родитель; компонент — чистая функция пропа value. Он рендерит то, что ты передаёшь, и сообщает о намерении через onChange — сам он ничего не запоминает. Это ментальная модель React, применённая к одному полю: UI = f(state), где state живёт в родителе. Ты получаешь полный контроль: вывести значение, провалидировать перед фиксацией, синхронизировать с URL, сбросить по требованию.

function Tabs({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  // no useState here — the active tab is whatever the parent says it is
  return (
    <div role="tablist">
      {["a", "b"].map((id) => (
        <button key={id} aria-selected={value === id} onClick={() => onChange(id)}>
          {id}
        </button>
      ))}
    </div>
  );
}
// caller owns the state and every transition
const [tab, setTab] = useState("a");
<Tabs value={tab} onChange={setTab} />;

Цена: каждый вызывающий обязан подключить useState и onChange, даже те, кому нужно лишь очевидное поведение.

2

Неуправляемый означает, что состоянием владеет компонент; родитель лишь задаёт его начальное значение через defaultValue. Компонент держит собственный useState, проинициализированный один раз из defaultValue, и больше никогда не читает этот проп. Вызывающий пишет одну строку и уходит. Ровно так ведёт себя нативный <input defaultValue="hi" />: ты задаёшь стартовый текст, остальное помнит DOM.

function Tabs({ defaultValue }: { defaultValue: string }) {
  const [value, setValue] = useState(defaultValue); // seeded once, then owned internally
  return (
    <div role="tablist">
      {["a", "b"].map((id) => (
        <button key={id} aria-selected={value === id} onClick={() => setValue(id)}>
          {id}
        </button>
      ))}
    </div>
  );
}
// caller does nothing but pick a starting tab
<Tabs defaultValue="a" />;

Цена: родитель не может прочитать значение или управлять им — ни синхронизации с URL, ни программного сброса, ни «провалидируй перед применением». Лаконично, но это тупик в тот момент, когда вызывающему нужен контроль.

3

Переиспользуемый виджет должен поддерживать оба режима из одной реализации — наличие пропа выбирает режим. Соглашение, которое использует сам React: если передан value, ты управляемый (побеждает проп); если его нет, ты неуправляемый (побеждает внутреннее состояние, проинициализированное из defaultValue). Всё решение сворачивается в один хук, useControllableState, общий для каждого переиспользуемого виджета. Он всегда держит ячейку внутреннего состояния как запасной вариант, но читает из пропа всякий раз, когда тот определён.

function useControllableState<T>(opts: {
  value: T | undefined;            // the controlled prop (undefined ⇒ uncontrolled)
  defaultValue: T;                 // seed for uncontrolled mode
  onChange?: (next: T) => void;    // notify the parent on every change
}): [T, (next: T) => void] {
  const isControlled = opts.value !== undefined;
  const [internal, setInternal] = useState(opts.defaultValue);
  const value = isControlled ? (opts.value as T) : internal;

  const setValue = (next: T) => {
    if (!isControlled) setInternal(next); // only own the state when uncontrolled
    opts.onChange?.(next);                // always report, so controlled parents hear it
  };
  return [value, setValue];
}

Обрати внимание на асимметрию: в управляемом режиме setValue не трогает внутреннее состояние — он лишь вызывает onChange и даёт пропу втечь обратно. Это сохраняет единый источник истины вместо двух конкурирующих копий.

4

У режима отказа два лица: навязывание только-управляемого режима и тихая смена режима по ходу жизни. Навяжи только-управляемый — и обложишь налогом каждый тривиальный вызов: одноразовой полоске вкладок на странице настроек теперь нужен собственный useState, ни за что. Именно это трение заставляет разработчиков бросать в остальном хорошие компоненты. Более тонкий баг — смена режима в течение жизни компонента: value, который начинается как undefined, а потом становится определённым (или наоборот), переключает компонент между владением и невладением своим состоянием, и React выдаёт классическое предупреждение «a component is changing an uncontrolled input to be controlled» — симптом потерянного или продублированного источника истины.

// BUG: value is undefined on first render, then defined → mode flips mid-life
function Field({ user }: { user?: User }) {
  // user?.name is undefined until the fetch resolves
  return <Tabs value={user?.name} onChange={onChange} />; // uncontrolled → controlled ⚠️
}

// FIX: commit to one mode. Coalesce to a stable default so value is never undefined…
<Tabs value={user?.name ?? "a"} onChange={onChange} />;
// …or go fully uncontrolled and let the widget own it:
<Tabs defaultValue={user?.name ?? "a"} />;

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

Разбор примера

Возьми только-управляемый виджет Rating и сделай его двухрежимным, не сломав существующих вызывающих. Вот жёсткая версия — каждый вызывающий вынужден владеть состоянием:

function Rating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
  return (
    <div>
      {[1, 2, 3, 4, 5].map((n) => (
        <button key={n} aria-pressed={n <= value} onClick={() => onChange(n)}>★</button>
      ))}
    </div>
  );
}
// a read-mostly "leave a review" form must still spin up useState just to display stars:
const [stars, setStars] = useState(0);
<Rating value={stars} onChange={setStars} />;

Теперь сделай value/onChange необязательными и пропусти оба через хук. Управляемые вызывающие не тронуты; неуправляемым достаётся одна строка:

function Rating({
  value,
  defaultValue = 0,
  onChange,
}: {
  value?: number;              // optional now — its presence picks the mode
  defaultValue?: number;
  onChange?: (n: number) => void;
}) {
  const [rating, setRating] = useControllableState({ value, defaultValue, onChange });
  return (
    <div>
      {[1, 2, 3, 4, 5].map((n) => (
        <button key={n} aria-pressed={n <= rating} onClick={() => setRating(n)}>★</button>
      ))}
    </div>
  );
}

// uncontrolled: terse, the widget remembers the stars itself
<Rating defaultValue={3} />;

// controlled: full control — needed because we submit the rating with a form action
const [stars, setStars] = useState(0);
<Rating value={stars} onChange={setStars} />;

Одна реализация, два контракта. Простой случай «покажи мне звёзды» — одна строка; требовательный случай «этот рейтинг — часть form action и должен пройти кругом через серверное состояние» сохраняет каждый рычаг. Этот диапазон — лаконично по умолчанию, управляемо когда важно — ровно то, что делает виджет переиспользуемым по всему приложению, а не только на экране, для которого он родился.

Почему это работает

Почему пусть режим решает наличие пропа, а не явный флаг mode="controlled"? Потому что наличие — это то, что уже делает нативная платформа (value против defaultValue у <input>), так что оно совпадает с тем, что руки каждого React-разработчика уже знают, — никакого нового API учить не нужно. Это ещё и делает управляемый путь невозможным забыть: если ты передаёшь value, ты обязуешься его питать, а TypeScript даже может спарить value с обязательным onChange через discriminated union, так что комбинация «управляемый без обработчика» не скомпилируется. Явный флаг — это второй источник истины о том, какой источник истины ты используешь: больше синхронизировать, больше шансов ошибиться.

Частая ошибка

Баг, который отгружают чаще всего: написать управляемую ветку так, что она тоже устанавливает внутреннее состояние (setInternal(next); onChange?.(next); без проверки isControlled). Теперь есть две копии значения — проп и устаревшая внутренняя ячейка — и они расходятся. Родитель, игнорирующий onChange (или валидирующий и отклоняющий изменение), всё равно видит, что UI сдвинулся, потому что внутренняя копия обновилась независимо. Правило единого источника истины — в этом весь смысл: в управляемом режиме setValue должен только уведомлять родителя и давать новому пропу value втечь обратно вниз. Если обновляются обе копии, у тебя больше нет управляемого компонента — у тебя рассинхронизированный.

Проверь себя
Викторина

useControllableState коллеги обновляет внутреннее состояние при каждом изменении И вызывает onChange, без проверки isControlled. Родитель рендерит виджет управляемым (передаёт value), но валидирует onChange и иногда отклоняет новое значение, не обновляя своё состояние. Что пойдёт не так?

Итог

Управляемый компонент даёт родителю владеть состоянием через value/onChange и рендерится как чистая функция пропа; неуправляемый владеет собственным useState, проинициализированным один раз из defaultValue, и родитель не может им управлять. Переиспользуемый виджет должен предлагать оба из одной реализации: useControllableState держит внутреннюю ячейку как запасной вариант, но читает проп value всякий раз, когда тот определён, а в управляемом режиме setValue лишь вызывает onChange — никогда не трогая внутреннее состояние, — так что есть единый источник истины. Предложить оба режима — это то, что делает виджет переиспользуемым в разных контекстах: тривиальный вызывающий остаётся однострочником (неуправляемый), требовательный получает каждый рычаг (управляемый). Режим отказа — навязать только-управляемый (обложив налогом каждое простое применение) или дать value переключаться между undefined и определённым по ходу жизни, что запускает предупреждение React об управляемом/неуправляемом. Выбери режим на первом рендере и держи его на всё время жизни компонента.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.