Неправильный контекст и индекс как key
Два антипаттерна, которые проходят демо и падают в проде: частое или локальное состояние в контексте (штормы перерисовок по всему дереву) и индекс как key (порча состояния списка при сортировке/вставке) — плюс их фиксы и ловушки перекоррекции.
Оба этих бага проходят ревью чисто. Код читается нормально, демо работает, PR мёржится. Потом приложение получает реальные данные и реальное взаимодействие — и один из них превращает плавный UI в дёрганый, а другой молча перепутывает, какой инпут какой строке принадлежит.
Это парные баги, потому что оба про неправильно сделанную идентичность. Неправильный контекст даёт состоянию неверную область — слишком широкую, так что постоянно меняющееся значение перерисовывает целое поддерево. Индекс как key даёт элементам списка неверную идентичность — позиционную вместо стабильной, так что React переиспользует не тот компонент при пересортировке списка. Ни один не проявляется на 3 элементах в демо; оба проявляются на 300 элементах, когда пользователи печатают.
После этого урока ты можешь распознать два антипаттерна с первого взгляда, объяснить точный сбой, который порождает каждый (штормы перерисовок по всему дереву от слишком широкого/частого контекста; порча состояния инпута/фокуса/выделения от индексных key на изменяемом списке), применить настоящие фиксы (разбей или перемести контекст, чтобы частое состояние не было общим; ключуй по стабильному id) и — что не менее важно — избежать перекоррекций: проброса всего через пропсы ради ухода от контекста или случайных key, которые перемонтируют каждую строку на каждый рендер.
Антипаттерн A: контекст, чьё значение часто меняется, перерисовывает каждого потребителя — как бы глубоко тот ни был. Provider контекста перерисовывает всех потребителей всякий раз, когда идентичность его value меняется. Положи частое состояние — управляемый инпут, позицию мыши, таймер — в широкий контекст, и каждый потребитель в поддереве перерисовывается на каждое нажатие клавиши или кадр. «Удобство» не прокидывать пропс оборачивается штормом рендеров по всему дереву.
// Антипаттерн: черновик формы (меняется на каждое нажатие) общий на всё приложение
const AppContext = createContext<{ draft: string; setDraft: (s: string) => void } | null>(null);
function App() {
const [draft, setDraft] = useState("");
// каждое нажатие → новый объект value → ВСЕ потребители перерисовываются
return (
<AppContext.Provider value={{ draft, setDraft }}>
<Header /> {/* перерисовывается на каждое нажатие */}
<Sidebar /> {/* перерисовывается на каждое нажатие */}
<Editor /> {/* единственное, кому draft реально нужен */}
</AppContext.Provider>
);
}Это проходит демо, потому что три дешёвых ребёнка, перерисовываясь, незаметны. В проде эти дети — дорогие деревья, и печать тормозит.
Фикс — это область, а не отказ: держи частое или чисто локальное состояние вне широкого контекста — колокируй его или разбей контекст по частоте изменений. Если черновик нужен только Editor, это состояние принадлежит Editor (или его ближайшему общему родителю), а не корню приложения. Когда состояние действительно общее, но смешивает стабильные и волатильные данные, разбей его: редко меняющийся контекст (тема, пользователь) остаётся широким; волатильный срез получает собственный узкий provider или вовсе выезжает наружу. Помни также, что свежий объект value={{ ... }} на каждом рендере сам по себе триггерит перерисовку — мемоизируй его, когда контекст законно остаётся широким.
// Фикс: черновик живёт там, где используется; контекст несёт лишь стабильные общие данные
const ThemeContext = createContext<Theme>("light"); // меняется редко → безопасно быть широким
function App() {
const theme = useTheme();
return (
<ThemeContext.Provider value={theme}>
<Header />
<Sidebar />
<Editor /> {/* владеет своим draft useState — нет churn по всему дереву */}
</ThemeContext.Provider>
);
}Ограничь область каждого куска состояния ровно теми, кому он нужен. Контекст — для общего состояния и работает лучше всего, когда это общее состояние меняется нечасто.
Антипаттерн B: key={index} привязывает идентичность компонента к его позиции, так что любая пересортировка/вставка/удаление заставляет React переиспользовать не тот инстанс. Key говорят React, какой элемент какой между рендерами. С индексными key «элемент на позиции 0» всегда key 0 — так что если ты вставляешь в начало, в середину или сортируешь, React думает, что новый элемент на позиции 0 — это тот же элемент, что и раньше, и держит внутреннее состояние старого компонента (текст инпута, фокус, выделение, скролл, чекбокс) привязанным к неверным данным.
// Антипаттерн: индексные key на списке, который пользователь может пересортировать / дополнять
{todos.map((todo, i) => (
<li key={i}>
{todo.label}
<input defaultValue={todo.note} /> {/* неуправляемое состояние живёт в DOM-узле */}
</li>
))}
// Вставь todo в начало → каждый инпут note теперь показывает текст ПРЕДЫДУЩЕЙ строки.Статичные, никогда не пересортируемые списки переживают индексные key — именно поэтому это проскакивает ревью: баг спит, пока список не мутирует.
Фикс — это стабильная идентичность: ключуй по значению, которое путешествует вместе с элементом — серверный id или сгенерированный id, созданный при рождении элемента, никогда не индекс массива и никогда Math.random(). Стабильный key означает, что React держит каждый компонент приклеенным к его собственным данным сквозь любую пересортировку, так что текст инпута, фокус и выделение следуют за нужной строкой. Обе перекоррекции проваливаются: key={Math.random()} производит новый key на каждом рендере, так что React перемонтирует каждую строку на каждом рендере (состояние потеряно, фокус потерян, худший случай). А key={JSON.stringify(item)} перекеширует всякий раз, когда меняется любое поле, перемонтируя при правке.
// Фикс: стабильный id, принадлежащий элементу
{todos.map((todo) => (
<li key={todo.id}>{todo.label}<input defaultValue={todo.note} /></li>
))}
// Если у источника нет id, отчекань его во время создания, а не во время рендера:
const addTodo = (label: string) =>
setTodos((t) => [...t, { id: crypto.randomUUID(), label, note: "" }]);Индексные key приемлемы только когда список статичен, никогда не пересортируется/фильтруется, а у элементов нет идентичности и нет внутреннего состояния — замороженное меню, а не редактируемый пользователем список.
Один экран редактора, оба бага, оба фикса. Пересортируемый список полей, у каждого свой текстовый инпут, плюс общий флаг «dirty» — наивно собранный, он везёт оба антипаттерна.
// ДО — неправильный контекст + индексные key
const EditorCtx = createContext<any>(null);
function Editor({ rows, setRows }) {
const [draft, setDraft] = useState(""); // частое
// ⚠️ новый объект на каждое нажатие → перерисовывается всё поддерево
return (
<EditorCtx.Provider value={{ draft, setDraft, rows, setRows }}>
<Toolbar /> {/* перерисовывается на каждое нажатие */}
{rows.map((row, i) => (
<Field key={i} row={row} /> {/* ⚠️ индексный key */}
))}
</EditorCtx.Provider>
);
}
// Вставь строку в начало → Field на индексе 0 держит текст/фокус старого инпута.Теперь оба фикса, каждый целит в свой сбой:
// ПОСЛЕ — состояние с областью + стабильные key
function Editor({ rows, setRows }) {
return (
<>
<Toolbar /> {/* больше не перерисовывается при смене draft */}
{rows.map((row) => (
<Field key={row.id} row={row} /> {/* стабильная идентичность следует за данными */}
))}
</>
);
}
function Field({ row }: { row: Row }) {
const [draft, setDraft] = useState(row.note); // draft колокирован → перерисовывается только этот Field
return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;
}Черновик теперь живёт в единственном компоненте, который его использует (нет provider, нет шторма), и каждый Field ключуется по row.id, так что пересортировка переприкрепляет каждый инпут к его собственным данным. Заметь дисциплину: мы не удалили контекст из страха (Toolbar/rows всё ещё могли бы делить контекст, если бы он им действительно был нужен), и мы не потянулись к случайным key — мы подобрали область под того-кому-нужно, а идентичность под элемент.
▸Почему это работает
Почему слишком широкий контекст стоит так дорого, когда «это всего лишь перерисовка»? Потому что контекст обходит обычный аварийный люк. Обычно неизменившийся пропс позволяет memo-ребёнку пропустить перерисовку. Но потребитель изменившегося контекста перерисовывается безусловно — memo его не останавливает. Так что частое значение в широком provider перерисовывает каждого потребителя в поддереве на каждое изменение, без поэлементного opt-out, кроме как реструктурированием. Вот почему фикс структурный (ограничь область / разбей контекст), а не россыпь memo.
▸Частая ошибка
Перекоррекция — вот настоящая ловушка, потому что выглядит как «безопасный выбор». Испугавшись перерисовок контекста, команда пробрасывает значение через восемь слоёв — теперь каждый промежуточный компонент перерисовывается всё равно и связывается с данными, которые не использует. Испугавшись индексных key, кто-то пишет key={Math.random()} — теперь каждая строка перемонтируется на каждом рендере, теряя фокус и состояние инпута на каждое нажатие, строго худший баг, чем тот, что он чинил. Правильные ходы точны: колокируй или разбей контекст (не упраздняй его) и ключуй по стабильному id (не по случайному и не выведенному из содержимого).
Пересортируемый список строк, у каждой неуправляемый <input>, использует key={index}. После перетаскивания-для-пересортировки инпуты показывают текст не тех строк. Коллега предлагает key={Math.random()}. Какой правильный фикс и что не так с предложением?
Два продовых бага, что проходят демо, оба про идентичность. Неправильный контекст: контекст перерисовывает каждого потребителя, когда меняется его value, и memo это не остановит — так что частое или чисто локальное состояние в широком provider становится штормом рендеров по всему дереву. Фикси областью: колокируй состояние там, где оно используется, разбей контекст по частоте изменений и мемоизируй объект value, когда контекст законно остаётся широким. Индекс как key: key={i} привязывает компонент к позиции, так что любая пересортировка/вставка/удаление переприкрепляет старое внутреннее состояние (текст инпута, фокус, выделение) к новым данным. Фикси стабильным id, что путешествует вместе с элементом — отчеканенным при создании, никогда не индексом, никогда Math.random() или хешем содержимого. У обоих есть соблазнительная перекоррекция, что хуже оригинала: проброс всего через пропсы вместо ограничения области контекста и случайные key, что перемонтируют каждую строку на каждом рендере. Senior-суждение здесь — точное прицеливание: верная область для состояния, верная идентичность для элементов списка, — а не сплошное избегание.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.