Колокация и подъём состояния
Держи состояние как можно ниже — в единственном компоненте, который его читает, — и поднимай только до ближайшего общего предка двух компонентов, которые им действительно делятся. Расположение — твой главный рычаг для ширины перерисовки и ясности.
Ты знаешь useState. Вопрос, на который отвечает этот урок, — тот, которому никто не учил тебя вместе с ним: какой компонент должен держать вызов? Это единственное решение — где живёт кусочек состояния — задаёт, какая часть дерева перерисуется при его изменении, сколько пропсов придётся пробрасывать и сможет ли будущий читатель увидеть проводку фичи в одном месте или будет вынужден гоняться за ней по пяти файлам.
Есть умолчание, которое тихо ломает и производительность, и ясность: поднимать всё наверх «на всякий случай, вдруг кому-то понадобится». Кажется безопасным. Оно порождает божественный компонент, который перерисовывает всю страницу на каждое нажатие клавиши и владеет десятком несвязанных обязанностей. Ход уровня senior — обратный рефлекс: толкай состояние вниз, пока что-нибудь тебя не остановит.
После этого урока ты можешь сформулировать правило колокации — держи состояние в самом нижнем компоненте, который его читает, — и его исключение — поднимай до ближайшего общего предка только тогда, когда два компонента действительно им делятся; распознавать переподнятое состояние по лишним перерисовкам и пробросу пропсов, которые оно вызывает; перенести кусочек состояния вниз, чтобы сжать след перерисовки; поднять действительно общий кусочек до правильного предка (не до самого верха); и назвать режим отказа подъёма-по-умолчанию — неформальный глобальный стор, носящий имя компонента.
Колокация: состояние принадлежит самому нижнему компоненту, который его реально читает. Если значение потребляет ровно одно поддерево — туда и идёт useState. Колокация — не предпочтение опрятности: когда состояние живёт в компоненте X, каждый рендер, вызванный этим состоянием, ограничен X и его детьми. Поставь его на уровень выше — и React перерисует весь этот уровень, включая соседей, которые никогда не касаются значения.
// колокация: состояние открыт/закрыт живёт в единственном, кто его использует
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<li>
<button onClick={() => setOpen((o) => !o)}>{q}</button>
{open && <p>{a}</p>}
</li>
);
}Переключение одного FAQ-пункта перерисовывает этот пункт. Список, шапка страницы, соседние пункты — нетронуты. Расположение состояния и есть граница перерисовки.
Переподнятое состояние — тихое умолчание, которое стоит тебе рендеров и проброса пропсов. Рефлекс «подними, чтобы было доступно» помещает значение далеко над его единственным читателем. Теперь React перерисовывает всё между владельцем и читателем на каждое изменение, а ты протягиваешь значение (и его сеттер) через компоненты, которым оно безразлично.
// переподнято: `draft` живёт в странице, но читает его только <Composer>
function InboxPage() {
const [draft, setDraft] = useState(""); // неправильный дом
return (
<Layout>
<Sidebar /> {/* перерисовка на каждое нажатие */}
<ThreadList /> {/* перерисовка на каждое нажатие */}
<Composer draft={draft} setDraft={setDraft} /> {/* единственный реальный читатель */}
</Layout>
);
}Каждый символ, набранный в <Composer>, перерисовывает <Sidebar> и <ThreadList>. Они каждый раз рендерят один и тот же вывод — чистая трата, — а draft/setDraft теперь без причины стали частью публичной поверхности страницы.
Исправление механическое: перенеси состояние вниз, в компонент, который его читает. Удали useState из родителя, выброси пропсы, которые пробрасывал, и объяви состояние внутри потребителя. След перерисовки схлопывается до этого потребителя; проброс пропсов исчезает.
// колокация: состояние перенесено в единственного читателя
function InboxPage() {
return (
<Layout>
<Sidebar />
<ThreadList />
<Composer /> {/* нечего пробрасывать */}
</Layout>
);
}
function Composer() {
const [draft, setDraft] = useState(""); // дом там, где читают
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}Теперь набор текста перерисовывает только <Composer>. <Sidebar> и <ThreadList> инертны во время редактирования. Никакого memo, никакого useCallback, никакой охоты с профайлером — просто правильный дом для состояния. Большинство багов «почему оно перерисовывается» — это баг колокации в маскировке, и перенос состояния вниз чинит их у источника, а не латает симптомы мемоизацией.
Поднимай вверх только тогда, когда два компонента действительно делятся состоянием, — и только до их ближайшего общего предка. Когда двум соседям нужно читать или писать одно и то же значение (фильтр, который нужен и <SearchBox>, и <Results>), ни один отдельный ребёнок не может им владеть. Подними его в ближайший компонент, содержащий оба, передай значение вниз, передай сеттеры вниз. Ближайший — не вершина приложения.
// общее: SearchBox пишет запрос, Results читает → подними к их родителю
function ProductSearch() { // ближайший общий предок обоих
const [query, setQuery] = useState("");
return (
<>
<SearchBox query={query} onChange={setQuery} />
<Results query={query} />
</>
);
}Правило симметрично колокации: как можно ниже, но достаточно высоко, чтобы покрыть каждого читателя. Подъём до ближайшего общего предка держит границу перерисовки узкой; подъём до корня делает границей всё приложение — а это ровно тот режим отказа, который называет следующая врезка.
Один кусочек вниз, другой вверх — один экран, два направления. Панель настроек имеет живой поисковый фильтр по списку и кнопку «сохранить всё», которой нужна вся отредактированная форма.
До: всё сидит в панели, «чтобы держать вместе».
function SettingsPanel({ fields }: { fields: Field[] }) {
const [filter, setFilter] = useState(""); // только <FieldList> это использует
const [values, setValues] = useState<FormValues>({}); // <Field>-ы + Save используют это
return (
<>
<Toolbar /> {/* перерисовка на каждое нажатие фильтра */}
<FieldList filter={filter} onFilter={setFilter} values={values} onChange={setValues} fields={fields} />
<SaveBar values={values} />
</>
);
}Набор в фильтре перерисовывает <Toolbar> и <SaveBar> ни за чем, а filter пробрасывается, хотя он важен только списку. Два отдельных исправления в противоположных направлениях:
// 1) filter читает ОДНО место → толкни его ВНИЗ в FieldList
function FieldList({ fields, values, onChange }: FieldListProps) {
const [filter, setFilter] = useState(""); // колокация
const shown = fields.filter((f) => f.label.includes(filter));
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{shown.map((f) => (
<Field key={f.id} field={f} value={values[f.id]} onChange={onChange} />
))}
</>
);
}
// 2) values действительно общее (каждый Field его пишет, SaveBar читает)
// → оно остаётся ПОДНЯТЫМ, но только до SettingsPanel — их ближайшего общего предка
function SettingsPanel({ fields }: { fields: Field[] }) {
const [values, setValues] = useState<FormValues>({});
return (
<>
<Toolbar /> {/* теперь инертен при фильтрации */}
<FieldList fields={fields} values={values} onChange={setValues} />
<SaveBar values={values} />
</>
);
}Теперь фильтрация перерисовывает только <FieldList>; <Toolbar> и <SaveBar> остаются на месте. values по-прежнему общее, но сидит у ближайшего предка своих читателей, не выше. Тот же экран, с каждым кусочком состояния на правильной высоте: один перенесён вниз, чтобы убить лишние рендеры, другой оставлен наверху, потому что разделение реально.
▸Частая ошибка
Режим отказа этого юнита — поднимать всё наверх «на всякий случай». useState, поднятый в корневой компонент, доступный всем, звучит гибко. На деле ты построил неформальный глобальный стор без единой гарантии стора: каждое обновление перерисовывает всё дерево, корень обрастает дюжиной несвязанных useState, пока не станет божественным компонентом, который никто не может прочесть, а пропсы пробрасываются через слои, существующие лишь для того, чтобы передать их дальше. «Доступно везде» — это симптом состояния, у которого нет настоящего дома, и лекарство почти всегда в том, чтобы толкнуть его вниз, в единственное место, которое его читает, а не узаконить божественный компонент через Context или Redux.
▸Почему это работает
Почему перенос состояния вниз — лучший первый ход, чем memo/useCallback? Потому что колокация устраняет лишний рендер; мемоизация лишь его пропускает, и за цену. Чтобы остановить перерисовку <Sidebar>, ты обернул бы его в React.memo, затем стабилизировал каждый пропс и колбэк, который он получает, через useMemo/useCallback, а затем держал бы это стабильным по мере изменения кода — постоянный налог на каждую правку. Перенос draft в <Composer> удаляет проблему: нет рендера, который надо пропускать, потому что состояние никогда не достигает <Sidebar>. Тянись к memo, когда компонент действительно общий и дорогой и уже в самом нижнем доме; сперва тянись к колокации.
Текст поискового поля хранится в useState в корневом <App>, но читает его только <ResultsList> тремя уровнями ниже. Набор тормозит, потому что вся страница перерисовывается на каждое нажатие. Каково исправление уровня senior?
Расположение состояния — твой главный рычаг и для производительности, и для ясности. Правило двустороннее: колокация — держи каждый кусочек состояния в самом нижнем компоненте, который его читает, чтобы граница перерисовки была как можно уже и ничего не пробрасывалось; и подъём — только когда два компонента действительно делятся значением, и только до их ближайшего общего предка, никогда выше. Переподнятое состояние — тихое умолчание: оно перерисовывает соседей, игнорирующих значение, и пробрасывает пропсы через слои, которым всё равно, а большинство багов «почему оно перерисовывается?» — это оно в маскировке, чинится переносом состояния вниз, а не россыпью memo. Режим отказа, от которого надо отказаться, — подъём-по-умолчанию: поднятие каждого useState наверх порождает божественный компонент, который и есть неформальный глобальный стор со всеми перерисовками и без единой гарантии. Толкай состояние вниз, пока реальное разделение тебя не остановит; затем поднимай ровно настолько, чтобы покрыть его читателей.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.