Управляемый против неуправляемого
Переиспользуемый виджет поддерживает оба режима — управляемый (состоянием владеет родитель через value/onChange) и неуправляемый (владеет компонент через defaultValue) — на одном хуке useControllableState, который предпочитает проп, если он задан, иначе берёт внутреннее.
Ты собрал виджет Tabs для своего юнита по compound-компонентам. Первому экрану, который его использует, нужны просто работающие вкладки — кликнул по вкладке, она показалась. Второму экрану нужно, чтобы активная вкладка жила в URL, синхронизировалась с кнопкой «дальше» и сбрасывалась при смене фильтра. Один и тот же виджет, два совершенно разных отношения к состоянию. Если твой Tabs поддерживает лишь одно из них, половина его пользователей будет с ним воевать.
Это дуальность управляемого/неуправляемого — старейший паттерн переиспользуемого компонента в React, тот самый, на котором построен сам <input>. Senior-виджет предлагает оба режима из одной реализации, чтобы тривиальное применение оставалось тривиальным, а требовательное получало полный контроль. Ошибка здесь рождает одно из самых печально известных рантайм-предупреждений React.
После этого урока ты можешь объяснить разницу между управляемым компонентом (состоянием владеет родитель через value/onChange) и неуправляемым (состоянием владеет компонент через defaultValue); реализовать хук useControllableState, который использует проп, когда он задан, и внутреннее состояние иначе; спроектировать API compound-виджета так, чтобы простые вызовы оставались лаконичными, а сложные получали полный контроль; и назвать режим отказа — навязывание только-управляемого режима или тихая смена режима по ходу жизни (баг с предупреждением о переходе управляемого в неуправляемый).
Управляемый означает, что состоянием владеет родитель; компонент — чистая функция пропа 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, даже те, кому нужно лишь очевидное поведение.
Неуправляемый означает, что состоянием владеет компонент; родитель лишь задаёт его начальное значение через 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, ни программного сброса, ни «провалидируй перед применением». Лаконично, но это тупик в тот момент, когда вызывающему нужен контроль.
Переиспользуемый виджет должен поддерживать оба режима из одной реализации — наличие пропа выбирает режим. Соглашение, которое использует сам 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 и даёт пропу втечь обратно. Это сохраняет единый источник истины вместо двух конкурирующих копий.
У режима отказа два лица: навязывание только-управляемого режима и тихая смена режима по ходу жизни. Навяжи только-управляемый — и обложишь налогом каждый тривиальный вызов: одноразовой полоске вкладок на странице настроек теперь нужен собственный 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.