Подъём состояния и колокация: состояние живёт у ближайшего общего владельца
Состояние живёт в нижнем компоненте, которому оно нужно. Поднимайте лишь до ближайшего общего владельца: каждый лишний уровень расширяет зону ре-рендера. «Глобально по умолчанию» делает нажатие клавиши рендером всего приложения; композиция через children изолирует состояние.
Страница настроек была самым медленным экраном продукта, и никто не мог объяснить почему: это была форма из девяти полей. Профайлер рассказал всё одним флейм-графом — одно нажатие клавиши в поле «отображаемое имя» ре-рендерило 1 400 компонентов, включая навигационную оболочку, бейдж уведомлений и таблицу тарифов на экране, на который пользователь даже не смотрел. Причиной была командная конвенция, записанная два года назад: «клади состояние в стор приложения, чтобы потом было доступно отовсюду». Черновое значение каждого инпута жило в корне. Каждое нажатие обновляло состояние на уровне корня, и React делал ровно то, что обещает: ре-рендерил владельца и обходил всё его поддерево. Починка заняла полдня и удалила код — черновики переехали в форму, форма опустила общие куски в самый нижний компонент, у которого реально были оба читателя, и рендер на нажатие упал с 1 400 компонентов до 12. Ничего не мемоизировали. Состояние просто переехало туда, где ему место.
К концу урока вы будете точно знать, куда перенести состояние, когда одно нажатие клавиши даёт 1 400 ре-рендеров — и почему лечение удаляет код, а не добавляет мемоизацию.
Один владелец: данные вниз, события вверх
Состояние в React локально по построению: useState принадлежит ровно одному экземпляру компонента, и этот экземпляр — единственный владелец, единственное место, где значение может измениться. Когда одно и то же изменяющееся значение нужно двум компонентам, его не дублируют (две копии обязательно разъедутся; баг-репорт звучит как «чип фильтра активен, а список не отфильтрован»). Его поднимают: находят самый нижний компонент, который является предком всех читателей — ближайшего общего владельца, — переносят состояние туда, передают значение вниз пропсами, а обработчики изменений — вниз детям, чтобы те могли запрашивать обновления. Данные текут вниз, события — вверх, и у каждого куска состояния один источник истины.
function SearchSection() {
// Ближайший общий владелец <SearchInput> и <ResultsList>:
// query нужен обоим, поэтому живёт здесь — и не выше.
const [query, setQuery] = useState("");
return (
<section>
<SearchInput value={query} onChange={setQuery} />
<ResultsList query={query} />
</section>
);
}
function SearchInput({ value, onChange }) {
// Контролируемый: компонент ничем не владеет; он сообщает намерение наверх.
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}Ключевое слово — ближайший. Подъём — это не «унести наверх», а «поднять ровно до первого общего предка и остановиться». Каждый уровень выше этой точки добавляет подписчиков, которым значение не нужно.
Зона поражения: почему высота — это решение о производительности
Когда вы видите компоненты, ре-рендерящиеся без видимой причины, первый вопрос — структурный: как высоко сидит их владелец? Когда состояние меняется, React ре-рендерит компонент-владельца и затем, по умолчанию, всё его поддерево — каждый потомок вызывается заново, читает он изменившееся значение или нет. (Ре-рендер — это дешёвая фаза render, а не мутация DOM, но вызывать 1 400 функций и согласовывать их вывод 60 раз, пока человек печатает, — не бесплатно.) Высота куска состояния, таким образом, задаёт его зону поражения: состояние в листе ре-рендерит лист; состояние в корне ре-рендерит приложение. Это и есть механизм анти-паттерна «глобально по умолчанию» — складывать всё в корневой стор или верхнеуровневый контекст не просто запутывает код: каждая запись становится рендером всего приложения, и дальше приходится отбиваться React.memo на сотнях компонентов. Колокация (colocation — хранение состояния там, где оно используется) — обратная дисциплина: заметив, что значение читает лишь одно поддерево, опустите состояние обратно вниз. Спуск состояния — чистый выигрыш: меньше рендеров, меньше протаскивания пропсов, и компонент становится удаляемым целиком.
▸Почему это работает
Почему React ре-рендерит всё поддерево, а не только компоненты, читающие изменившееся состояние? Потому что с пропсами «читает» — не подписка, которую React способен увидеть: любой потомок может получать значение, выведенное из этого состояния произвольным JavaScript. Единственное безопасное допущение после ре-рендера владельца — что пропсы любого ребёнка могли измениться, поэтому React перезапускает их и даёт reconciliation отбросить идентичный вывод. Точечные обновления по подписке требуют явного контракта — селекторов внешних сторов, это урок 3 этого юнита. С обычным состоянием ваш рычаг структурный: держите владельца низко.
Композиция: изоляция состояния через проп children
Есть структурный приём, который для типового случая бьёт мемоизацию: передавайте тяжёлые поддеревья как children. Дети в JSX — это элементы, объекты, создаваемые там, где JSX написан, а не там, где он рендерится. Если обёртка с состоянием получает содержимое через children, её ре-рендер переиспользует те же самые объекты-элементы, что ей передали (родитель, который их создал, не ре-рендерился). React видит идентичную ссылку на элемент и выходит досрочно, не ре-рендеря это поддерево вовсе.
function ScrollTracker({ children }) {
const [y, setY] = useState(0); // обновляется на каждый тик скролла
// children создал родитель, и они не менялись —
// та же ссылка на элемент, поэтому React пропускает их ре-рендер.
return <div onScroll={(e) => setY(e.target.scrollTop)}>
<ProgressBar y={y} />
{children}
</div>;
}
// <ScrollTracker><HeavyTable rows={rows} /></ScrollTracker>
// HeavyTable НЕ ре-рендерится на скролл.Это изоляция состояния архитектурой: высокочастотное состояние (скролл, hover, черновики ввода) получает узкий компонент для жизни, а тяжёлое содержимое проходит сквозь него нетронутым. Компромисс: подъём и колокация — это рефакторинг при каждом сдвиге требований: значение, нужное сегодня одному компоненту, завтра получает второго читателя, и его придётся переносить. Это трение реально, и это честная цена контроля зоны поражения; команды, отказывающиеся её платить, сползают обратно к «глобально по умолчанию» и платят рендерами. Сценарий отказа: дублирование состояния вместо подъёма («заведу ещё и локальную копию») — два владельца, неизбежное расхождение и класс багов, где UI противоречит сам себе.
Двум компонентам-сиблингам нужно читать и обновлять одно значение фильтра. Каков правильный ход и почему именно туда?
Обёртка с быстро меняющимся состоянием скролла получает тяжёлую таблицу через проп children. Почему таблица не ре-рендерится при обновлении состояния обёртки?
Значение фильтра читают и обновляют два компонента-сиблинга на одной странице. Где должен жить единственный useState?
- 01Объясните механизм, связывающий высоту куска состояния со стоимостью рендеринга, и что операционно значит «ближайший общий владелец».
- 02Почему передача дорогого поддерева как children изолирует его от ре-рендеров обёртки с состоянием, и когда это лучше React.memo?
У состояния в React ровно один владелец, и архитектура — это решение, насколько высоко он сидит. По умолчанию — колокация: состояние живёт в самом нижнем компоненте, которому нужно. Когда значение делят сиблинги, его поднимают: найдите ближайшего общего предка всех читателей, перенесите туда единственный useState, значение передавайте вниз пропсами, намерения — вверх обработчиками. Один источник истины, без дублирования и синхронизирующих эффектов. Модель стоимости, из-за которой высота важна: запись в состояние ре-рендерит владельца и всё его поддерево, потому что React не может знать, какие потомки читают значение через пропсы — каждый уровень выше ближайшего общего владельца добавляет компоненты, ре-рендерящиеся впустую. «Глобально по умолчанию» — эта ошибка, возведённая в правило: корневой стор для черновиков ввода превратил одно нажатие клавиши в рендер 1 400 компонентов в истории из Hook, и именно спуск состояния — не мемоизация — сократил его до 12. Структурное дополнение — композиция: обёртка, владеющая быстрым состоянием и получающая тяжёлое содержимое через children, ре-рендерится одна, потому что элементы children создал родитель, ссылка не изменилась, и React выходит из них досрочно. У подъёма честная цена — состояние переезжает при смене читателей, и этот рефакторинг платится снова и снова, — но альтернатива — платить зоной поражения на каждую запись. Опускайте состояние при сужении читателей; поднимайте, только когда они действительно расходятся. Теперь, когда вы увидите в профайлере флейм-граф из компонентов, которые не должны ре-рендериться, первый вопрос — структурный: как высоко сидит владелец, и можно ли его опустить?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.