Капстоун I: инвентаризация паттерн-смеллов
Прежде чем рефакторить запутанную фичу React, поставь диагноз: назови каждый анти-паттерн словарём трека, найди его и отранжируй по влиянию (лишние рендеры, скрытые баги, стоимость изменения), чтобы каждая правка доказала, что что-то улучшила.
Тебе достаётся фича Dashboard: один компонент на 500 строк, пропсы протянуты на шесть уровней вглубь, «выбранный фильтр», который копируется в состояние эффектом, единственный контекст, держащий всё, чего фича касается, и список с ключами по индексу массива. Оно работает — пока ты ничего не меняешь. Каждая правка перерисовывает всё дерево, фильтр иногда показывает устаревшие данные, а переупорядочивание списка путает, какая строка «открыта».
Инстинкт джуна — начать чинить: убрать эффект, разбить компонент, заменить ключи. Инстинкт senior — остановиться и сперва инвентаризовать. Ты не сможешь доказать, что рефакторинг что-то улучшил, если так и не записал, что было не так, где и какой ценой. Этот капстоун начинается там, где должен начинаться каждый реальный рефакторинг: с диагноза, названного словарём этого трека и отранжированного по влиянию.
После этого урока ты можешь взять запутанную фичу React и составить инвентаризацию паттерн-смеллов: назвать каждый анти-паттерн терминами этого трека (божественный компонент, проброс пропсов, состояние, выведенное эффектом, толстый контекст, ключи по индексу), найти его по файлу и форме строк и отранжировать смеллы по влиянию по трём осям — лишние перерисовки, скрытые баги и стоимость изменения, — чтобы у каждого последующего шага рефакторинга была база, относительно которой его можно измерить.
Смелл первый — божественный компонент: один компонент, владеющий множеством несвязанных обязанностей, поэтому у него много причин меняться и один гигантский рендер. Ищи единственный компонент, который грузит данные, фильтрует, сортирует, пагинирует, держит выбор и рендерит. Это не «большой компонент», это магнит связанности: любое изменение состояния перерисовывает всё это целиком, а любое изменение фичи рискует всем этим.
// smell: Dashboard owns 7 concerns and ~500 lines
function Dashboard() {
const [data, setData] = useState<Row[]>([]);
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<keyof Row>("name");
const [page, setPage] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
// ...fetch effect, filter, sort, paginate, render table + toolbar + detail
}Локализуй точно: метрика — «сколько обязанностей во владении». Здесь семь. Семейство исправлений — композиция + кастомные хуки, но ты его пока не применяешь — ты его фиксируешь.
Смелл второй — проброс пропсов: передача данных через компоненты, которые их не используют, лишь бы дотянуться до глубокого потребителя. Проследи за одним пропсом. Если onSelect или currentUser объявлен в Dashboard, принят Table, проброшен TableBody, проброшен Row и используется только в RowActions, то каждый промежуточный компонент связан изменением с пропсом, до которого ему нет дела, — и перерисовывается, когда тот меняется.
// smell: onSelect threaded through 4 components that don't use it
<Table onSelect={onSelect}> {/* passes down */}
<TableBody onSelect={onSelect}> {/* passes down */}
<Row onSelect={onSelect}> {/* passes down */}
<RowActions onSelect={onSelect} /> {/* finally uses it */}Семейство исправлений — контекст или композиция (подними разметку так, чтобы потребитель стал прямым потомком). Зафиксируй глубину: здесь четыре прыжка. Глубина проброса — твой сигнал стоимости изменения.
Смелл третий — состояние, выведенное эффектом: эффект, который копирует или вычисляет один кусок состояния из другого, создавая скрытый баг устаревания. Это смелл наивысшей серьёзности, потому что он не просто медленный, он неверный. Эффект, записывающий filtered из data + query, выполняется после рендера, поэтому есть кадр, где filtered устарел, а любая пропущенная зависимость делает его устаревшим навсегда.
// smell: derived state via effect — stale by a frame, fragile by deps
const [filtered, setFiltered] = useState<Row[]>([]);
useEffect(() => {
setFiltered(data.filter((r) => r.name.includes(query)));
}, [data, query]); // forget one dep → permanently staleИсправление — вывести во время рендера (const filtered = data.filter(...)), без эффекта, без второго состояния. Ранжируй это ближе к верху: это баг корректности, а не производительности.
Смелл четвёртый — толстый контекст: один контекст, держащий множество несвязанных значений, поэтому каждый потребитель перерисовывается, когда меняется любое одно значение. DashboardContext, связывающий query, selectedId, theme и data, означает, что компонент, читающий только theme, перерисовывается на каждое нажатие клавиши, меняющее query. Обновления контекста — всё-или-ничего на значение провайдера.
// smell: unrelated values in one context → keystroke re-renders theme consumers
const DashboardContext = createContext<{
query: string; selectedId: string | null; theme: Theme; data: Row[];
} | null>(null);Семейство исправлений — разделить контексты (или вынести изменчивое состояние из контекста вовсе). Зафиксируй число потребителей и частоту обновлений: это произведение — твоя оценка лишних рендеров.
Смелл пятый — ключи по индексу на переупорядочиваемом списке: key={i} привязывает идентичность React к позиции, а не к элементу, поэтому переупорядочивание или удаление портит состояние строк. Это скрытый баг, а не просто предупреждение линтера: когда строки несут локальное состояние (открытый/закрытый детальный блок, черновик ввода), ключи по индексу заставляют это состояние следовать за слотом, а не за строкой.
// smell: key is the index, but rows reorder and carry open/closed state
{rows.map((row, i) => <RowDetail key={i} row={row} />)} // delete row 0 → row 1's state shows on row 0Исправление — стабильный ключ по id (key={row.id}). Серьёзность зависит от того, переупорядочивается ли список и держат ли строки состояние, — здесь верно и то, и другое, так что это настоящий баг, а не придирка. Зафиксируй этот контекст, а не только строку.
Сама инвентаризация — результат этой части капстоуна. Получив фичу Dashboard выше, ты не пишешь ни одного исправления. Ты пишешь вот это:
// pattern-smell inventory — Dashboard feature
// each row: smell · location · impact axis · severity · fix family
//
// P0 effect-derived state Dashboard L40-44 filtered = useEffect(data,query)
// impact: LATENT BUG — stale-by-a-frame + dep-fragility; can render wrong data
// fix: derive during render, delete the effect + the second useState
//
// P0 index keys (stateful) Table L120 rows.map((r,i)=><RowDetail key={i}/>)
// impact: LATENT BUG — list reorders AND rows hold open/closed state → corruption
// fix: key={row.id}
//
// P1 fat context DashboardContext L8 {query,selectedId,theme,data}
// impact: WASTED RENDERS — ~12 consumers re-render on every keystroke
// fix: split into per-concern contexts; move volatile state out
//
// P1 god component Dashboard L20-500 owns 7 concerns
// impact: WASTED RENDERS + change-cost — any state change re-renders all
// fix: composition (<Toolbar>/<DataTable>/<Detail>) + useDashboardData()
//
// P2 prop drilling onSelect Dashboard→Table→TableBody→Row→RowActions
// impact: CHANGE-COST — 4 hops change-coupled to a prop they don't use
// fix: context for the callback, or lift markup so consumer is a childЗаметь логику ранжирования: корректность бьёт производительность, производительность бьёт эргономику. Два скрытых бага (P0) идут первыми, потому что они могут отгрузить пользователю неверные данные — никакая экономия рендеров не важна, если вывод неправилен. Два смелла рендеров (P1) идут следом, упорядоченные по радиусу поражения. Проброс (P2) реален, но стоит только времени разработчика, а не корректности для пользователя, поэтому он последний. Именно этот порядок делает следующие уроки измеримыми: когда ты починишь состояние из эффекта, ты сможешь указать на «P0, скрытый баг устаревания, устранён» — доказательство, а не ощущения.
▸Почему это работает
Зачем инвентаризовать до правок, если ты уже видишь исправления? Потому что рефакторинг без базы нельзя защитить ни на ревью, ни перед самим собой. «Я разбил компонент» напрашивается на «а что-то действительно ускорилось, или ты просто переместил строки?». Инвентаризация отвечает на это: каждое исправление отображается на названный смелл с зафиксированным влиянием, так что каждый PR закрывает конкретный, заранее заявленный пункт. Она ещё и принуждает к ранжированию, что не даёт потратить первый час на приятный-но-малозначимый проброс пропсов, пока баг устаревания продолжает отгружать неверные данные. Диагноз — это та часть, что делает лекарство доказуемым.
▸Частая ошибка
Режим отказа, ради предотвращения которого существует этот урок: правки до инвентаризации. Ты замечаешь ключи по индексу, чинишь их за пять минут, чувствуешь себя продуктивным — и теперь дифф замутнён, ты забыл остальные четыре смелла и не можешь показать, что что-то системно улучшилось, потому что никогда не было «до» для сравнения. Хуже того, починка косметических смеллов первыми (проброс, разбиение божественного компонента) часто прячет баги корректности, перемещая их, так что они доживают до продакшена с виду отрефакторенными. Сперва напиши всю отранжированную инвентаризацию. Трогай код вторым шагом. Порядок и есть дисциплина.
В инвентаризации Dashboard почему состояние, выведенное эффектом, и ключи по индексу отранжированы как P0 — выше толстого контекста и божественного компонента, — хотя те двое вызывают куда больше перерисовок?
Рефакторинг начинается с диагноза, а не правки. Получив запутанную фичу React, составь инвентаризацию паттерн-смеллов: назови каждый анти-паттерн словарём этого трека — божественный компонент (много обязанностей, широкие рендеры), проброс пропсов (глубокая сквозная передача, стоимость изменения), состояние, выведенное эффектом (скрытый баг устаревания), толстый контекст (перерисовки потребителей на нажатие клавиши) и ключи по индексу (переупорядочивание портит состояние строк) — найди каждый по файлу и форме строк и отранжируй их по трём осям влияния: лишние перерисовки, скрытые баги, стоимость изменения. Правило ранжирования — корректность бьёт производительность бьёт эргономику, поэтому два скрытых бага сидят на P0 выше смеллов рендеров и проброса. Результат этой части капстоуна — та самая отранжированная инвентаризация, а не единственное исправление, — потому что режим отказа здесь это правки до инвентаризации, после которых ты не можешь доказать, что что-то улучшилось. С записанной базой каждый последующий шаг рефакторинга закрывает названный, заранее заявленный пункт.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.