пошКОДим: как превратить React-компонент в неуправляемый комбайн — 15 вредных советов
- воскресенье, 26 июля 2026 г. в 00:00:07

Комбайн не пишут — его выращивают. По одному разумному шагу за спринт.
На планировании оценивают задачу: «добавить бейдж VIP на карточку клиента». Разработчик открывает CustomerCard.tsx, молчит и говорит: «Дня три». Никто не смеётся — все открывали этот файл. В нём почти семьсот строк, и он умеет всё: грузит клиента и его заказы, кэширует, валидирует форму, различает три роли, экспортирует CSV и не падает. Сцена собирательная, но файл — нет: для этой статьи я вырастил его сам, шаг за шагом, и каждый шаг сохранил.
Вопрос к такому файлу — не «почему он большой?». Вопрос — «сколько у него причин измениться и кто их считает, кроме ревьюера?»
За годы code review я много раз наблюдал рождение комбайна и ни разу — конкретный момент, когда обычный компонент становится неуправляемым. Такого момента нет: каждое отдельное изменение выглядит разумным, укладывается в дедлайн и проходит ревью. Поэтому статья устроена как хроника: один компонент, пятнадцать вредных советов, и после каждого — счёт, который выставляет машина.
Неуправляемость не измеряется строками — строки лишь симптом. Болезнь — число причин изменения, собранных в одном файле. Пока их никто не считает, комбайн растёт легально: каждый его шаг проходит code review.
Курсивом в кавычках говорит «бывалый» с вредным советом. Следовать ему не надо.
Все 16 версий собираются и запускаются. Основной сценарий сохраняется, но по мере деградации появляются воспроизводимые дефекты. После каждого шага стенд печатает набор метрик; часть вычисляется через AST. Формула авторского счёта ветвлений будет рядом с графиками, а числа воспроизводятся командой pnpm report.
Открыть проект с playground и всеми версиями компонента
Карточка клиента в CRM: профиль, заказы и контакты. Пока ей не давали вредных советов, это два файла — слой данных (68 строк кода) и компонент (55 строк кода).
// v00 — компонент видит доменную модель, а не ответ сервера export function CustomerCard({ customerId }: CustomerCardProps) { const { customer, loading, error } = useCustomer(customerId); if (loading) { return <div className="card card--placeholder">Загружаем клиента…</div>; } if (error !== null || customer === null) { return <div className="card card--error">Не удалось загрузить клиента: {error}</div>; } return ( <article className="card"> <header className="card__header"> <h2>{customer.fullName}</h2> <span className={`badge badge--${customer.status}`}>{STATUS_LABEL[customer.status]}</span> </header> {/* email, телефон, адрес, дата — обычные строки описания */} </article> ); }
Точка отсчёта: 123 строки кода · 4 хука · AST-счёт ветвлений 7 · 2 условия в JSX · монтирование в тесте — 9 строк.
Запомните эти числа. Дальше они будут только расти. Почти.
Запрос из спринта: показать карточку клиента. Уже сделано, но на ревью приходит опытный голос:
«Двадцать строк на хук ради одного запроса? Плюс отдельный файл, плюс импорты. Перенеси fetch в компонент — всё в одном месте, открыл и видишь».
// 😈 v01 — fetch, DTO и маппинг переехали в компонент, api.ts удалён export function CustomerCard({ customerId }: CustomerCardProps) { const [customer, setCustomer] = useState<Customer | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const controller = new AbortController(); fetch(`/api/customers/${customerId}`, { signal: controller.signal }) .then((response) => response.json()) .then((dto) => setCustomer(mapCustomer(dto))) // … }, [customerId]);
Компонент перестал отвечать на вопрос «что показать» и начал отвечать ещё и на вопрос «откуда взять». Это первая дополнительная причина изменения: теперь смена API-протокола — правка UI-файла.
После v01: 114 строк кода (−9) · файлов: 2 → 1.
Обратите внимание: деградация началась с уменьшения кода. «Одним файлом меньше» выглядит упрощением — на ревью такой диф соберёт одобрительные комментарии.
Вернуть границу можно хуком useCustomer: компонент получает данные и не знает о fetch. Тогда способ обращения к API меняется без правки разметки.

Слайдер переключает рабочие снапшоты, диф и измерения; здесь открыта исходная карточка.
Подключили реальный API. Он отдаёт snake_case с вложенностью.
«Маппинг — это работа ради работы: написал, покрыл тестом, а он просто перекладывает поля. Храни ответ сервера как есть — TypeScript сам всё выведет».
// 😈 v02 — DTO сервера в состоянии, разметка сама достаёт вложенные поля <dd>{customer.contact_info?.phone_numbers?.[0] ?? "не указан"}</dd> <dd> {customer.contact_info?.address?.city ?? "—"},{" "} {customer.contact_info?.address?.street ?? "—"} </dd> <dd>{new Date(customer.created_at).toLocaleDateString("ru-RU")}</dd>
Формат ответа сервера протёк в каждую строку разметки. Опциональные цепочки теперь страхуют даже обязательные данные: код уже не уверен в собственном контракте. Когда бэкенд переименует contact_info, диф пройдёт по всему экрану; про перевод внешних данных в доменную модель я подробно писал в статье про any и границу приложения.
v02: 95 строк кода (−19) · AST-счёт ветвлений 15 (+5). Счёт учитывает условные выражения и ??, но не ?.; опциональные цепочки здесь важны как сигнал протёкшего DTO.
Два совета — минус 28 строк. Комбайн пока выглядит как диета.
Здесь достаточно одного маппера на границе: DTO остаётся в файле клиента API.
Запрос: показать историю заказов.
«Второй запрос — второй useEffect, симметрично же. И свои флажки ordersLoading, ordersError, чтобы не путались с первыми».
// 😈 v03 — второй запрос со своим набором состояний const [orders, setOrders] = useState<OrderDto[] | null>(null); const [ordersLoading, setOrdersLoading] = useState(true); const [ordersError, setOrdersError] = useState<string | null>(null); useEffect(() => { // …ещё один fetch, ещё одна обвязка abort/error/loading }, [customerId]);
Два запроса с тремя состояниями каждый дают девять комбинаций статусов. Код не моделирует их как конечный автомат — порядок if и JSX-веток неявно расставляет приоритеты. Например, клиент загрузился, а заказы упали: профиль остаётся на экране рядом с ошибкой. Такой UX допустим, если команда выбрала его сознательно; здесь его определил порядок условий.
После v03: 166 строк кода (+71) · хуков 8 (+4) · AST-счёт ветвлений 28 (+13) · условий в JSX 12 (+10).
Рабочая граница — отдельный хук на ресурс: useOrders рядом с useCustomer. Комбинации статусов останутся, зато состояние каждого запроса будет собрано в одном месте.
Пользователи щёлкают по списку клиентов, и каждое переключение — новый запрос.
«Кэш пишется за пять минут: словарик в useRef, перед запросом заглянул, после — положил. Библиотека для этого не нужна».
// 😈 v04 — кэш на useRef; про инвалидацию решили подумать потом const customerCacheRef = useRef<Record<string, CustomerDto>>({}); useEffect(() => { const cached = customerCacheRef.current[customerId]; if (cached !== undefined) { setCustomer(cached); setLoading(false); return; } // …fetch и запись в кэш }, [customerId]);
Написать кэш просто. Сложно ответить, когда он перестаёт быть правдой, — а этот не перестаёт никогда. Пока карточка только читает, баг спит. Он проснётся через один совет, когда появится сохранение: отредактировали клиента → переключились на другого → вернулись → на экране старые данные. В playground этот баг можно потрогать начиная с v05.
На v04 уже 188 строк, 10 хуков и AST-счёт ветвлений 30.
Кэш стоит держать в слое данных. TanStack Query и SWR дают готовые механизмы хранения, дедупликации и инвалидации; политику актуальности всё равно определяет приложение.
Итог акта I. Четыре совета — и компонент стал владельцем сетевых запросов, формата данных, гонок и кэша. Четыре новые причины изменения, ни одна не про интерфейс. Самое коварное: два первых шага сократили код — граница исчезала под видом упрощения.
Запрос: редактирование контактов прямо в карточке.
«Семь полей — семь useState. Это же изолированно: поле меняется — обновляется только оно. Плюс editMode, isDirty, isSaving — и готово».
// 😈 v05 — «форма» = десять переменных, которые должны меняться согласованно const [editMode, setEditMode] = useState(false); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); const [city, setCity] = useState(""); const [street, setStreet] = useState(""); const [zip, setZip] = useState(""); const [isDirty, setIsDirty] = useState(false); const [isSaving, setIsSaving] = useState(false);
Само количество useState не проблема. Здесь поля образуют одну форму, меняются согласованно и допускают невозможные промежуточные комбинации. Но в коде вместо модели формы — десять переменных и startEdit из десяти сеттеров. Забыли один — «Отмена» вернула старый телефон. React в Choosing the State Structure советует группировать состояние, которое меняется вместе.
v05 дал самый большой скачок объёма: 345 строк кода (+157) · хуков 20 (+10) · AST-счёт ветвлений 42.
Для этой формы практичнее одно значение — объект в useReducer или модель библиотеки форм. Тогда патч для сервера собирается из state.values, а не из десяти разрозненных переменных.
Запрос: показать заполненность профиля и сумму заказов.
«Посчитал один раз — сохрани в состояние. Нечего пересчитывать на каждый рендер, это же оптимизация».
// 😈 v06 — производное значение живёт отдельно и синхронизируется эффектом const [profileCompleteness, setProfileCompleteness] = useState(0); useEffect(() => { if (customer === null) { setProfileCompleteness(0); return; } const fields = [/* email, телефон, город, улица, индекс */]; setProfileCompleteness(Math.round((filled / fields.length) * 100)); }, [customer]);
У заполненности профиля теперь два источника правды: сам клиент и число в состоянии. Между setCustomer и срабатыванием эффекта они противоречат друг другу — окно шириной в рендер, из которого потом вырастают «мигающие» проценты. Это тот же второй источник правды, который я разбирал в статье про useEffect, — здесь он появился под флагом оптимизации.
v06: 383 строки кода · хуков 24 (+4: два состояния и два эффекта для производных значений).
Производное значение можно вычислить в рендере: const completeness = profileCompleteness(customer). useMemo понадобится только при измеренной дорогой операции.
Запрос: телефон и email надо проверять.
«Пиши правила рядом с полями — где поля, там и валидация. Функция на полсотни строк в том же файле, зато всё перед глазами».
// 😈 v07 — правила домена намертво пришиты к UI function validate(): Record<string, string> { const errors: Record<string, string> = {}; if (firstName.trim() === "") { errors.firstName = "Имя обязательно"; } else if (firstName.trim().length < 2) { /* … */ } if (email.trim() === "") { /* … */ } if (zip.trim() !== "" && !/^\d{6}$/.test(zip.trim())) { /* … */ } if (zip.trim() !== "" && city.trim() === "") { /* … */ } // …ещё десяток веток return errors; }
«Индекс — шесть цифр» — правило валидации данных, а не обязанность JSX-разметки. Пока оно замкнуто на переменные компонента, проверить его можно единственным способом: отрендерить карточку, кликнуть «Редактировать», напечатать значение, нажать «Сохранить» и поискать текст ошибки в DOM. Секунды вместо миллисекунд — за проверку чистой логики.
В v07 уже 445 строк, а AST-счёт ветвлений вырос до 72 (+26 за один шаг).
validateContacts(fields) может быть чистой функцией от значений. Для её теста достаточно обычного вызова с объектом.
Сервер тоже валидирует и возвращает 422.
«У тебя уже есть formErrors — мержи туда серверные ошибки, поля-то те же. Пользователю всё равно, кто ошибку нашёл».
// 😈 v08 — две юрисдикции в одном словаре if (response.status === 422) { const payload = await response.json(); setFormErrors((prev) => ({ ...prev, ...payload.errors })); return null; }
Клиентские ошибки пересчитываются при вводе или submit, серверные появляются позже. После слияния словарь не хранит источник и время жизни записи: ошибка сервера про email может висеть даже после исправления поля.

На экране всё выглядит аккуратно. Проблема спрятана во времени жизни ошибки: после исправления поля её владелец остаётся неясным.
v08 добавил всего 9 строк и 2 пункта к AST-счёту. Метрика почти не изменилась, хотя модель ошибок потеряла важную информацию.
Происхождение и время жизни ошибок нужно отразить в модели: отдельными clientErrors и serverErrors либо типизированным union с полем source.
Итог акта II: компонент теперь хранит черновик, валидирует его и переводит серверные ошибки. AST-счёт вырос с 30 до 74; большая часть новых веток проверяется только через рендер.
Запрос: менеджер не должен видеть скидку, наблюдатель — полный телефон и заказы.
«Права — это просто if в разметке: role === "admin" && …. Декларативно, видно прямо в вёрстке, никакой лишней абстракции».
// 😈 v09 — правило «кто видит скидку» размазано по разметке {currentUser.role === "admin" && (customer.discount_percent ?? 0) > 0 && ( <div className="card__row"> <dt>Скидка</dt> {/* … */} </div> )} {(currentUser.role === "manager" || currentUser.role === "admin") && ( <button className="card__edit" onClick={startEdit}>Редактировать</button> )}
В v09 сравнение currentUser.role со строкой встречается девятнадцать раз. Правило «кто видит скидку» не существует как правило — оно существует как девятнадцать независимых утверждений, которые дрейфуют по отдельности. Добавится роль «супервайзер» — искать придётся все, и велик шанс, что одна из проверок найдётся уже после релиза.
v09: 508 строк кода · AST-счёт ветвлений 94 (+20) · условий в JSX 38 (+13) · настройка монтирования выросла с 9 до 14 строк.
Таблица прав и can(role, action) собирают UI-правило в одном месте и позволяют проверить его перебором. Это управляет отображением интерфейса, но не заменяет серверную авторизацию.
Карточку захотели в модалке и компактно в списке.
«Не плодить же второй компонент. isCompact, isModal, hideOrders — три флажка, и одна карточка работает везде».
// 😈 v10 — конфигурация вместо композиции interface CustomerCardProps { customerId: string; currentUser: CurrentUser; isCompact?: boolean; isModal?: boolean; hideOrders?: boolean; onClose?: () => void; }
Три флажка — восемь комбинаций, осмысленных четыре. Что значит isCompact вместе с isModal? А hideOrders при isCompact, который и так прячет заказы? Ответов нет — есть поведение, которое «как-то получилось». В v10 кнопка «Редактировать» в компактном режиме нажимается, но форма не рендерится: editMode включён, экран не изменился. Это не спроектированный кейс — это осадок от пересечения флажков, и никакой тип его не запрещает.
На v10 — 530 строк, 6 пропсов (+4) и 47 условий в JSX: пик таймлайна.
Здесь помогает композиция: CustomerHeader, OrdersSection и отдельные сборки под конкретные контексты. Неиспользуемые комбинации флагов исчезают из публичного контракта.
Файл перевалил за 500 строк, читать тяжело.
«Разбей render на функции прямо внутри компонента: renderHeader(), renderOrders(), renderForm(). Как компоненты, только без пропсов — ничего не надо прокидывать».
// 😈 v11 — псевдодекомпозиция: секции замкнуты на всё состояние function renderForm() { if (customer === null) return null; if (!editMode || isCompact) return null; return ( <form className="card__form" /* … всё то же, только глубже */>
«Ничего не надо прокидывать» — это и есть диагноз. У секции нет контракта: она видит все 26 хуков и может трогать любой. Механически вынести её в отдельный файл не получится: сначала придётся спроектировать контракт и явно перечислить зависимости. А ещё каждая секция заново проверяет customer на null, потому что сужение типов не переживает границу функции.
v11: 553 строки кода · условий в JSX 37 (−10) · AST-счёт ветвлений 106 (+3).
Условия в JSX формально «улучшились»: ветвления переехали в ранние возвраты секций. Одна метрика легко даёт ложный положительный сигнал; здесь её приходится читать вместе с AST-счётом.
Если секции дать пропсы и вынести в компонент, у неё появится проверяемый контракт и ограниченный доступ к состоянию.
Восемь onChange выглядят одинаково.
«Сделай один handleFieldChange со switch по event.target.name. Типы здесь только мешают — новая строка, новая ветка».
// 😈 v12 — имена полей стали магическими строками function handleFieldChange(event: React.ChangeEvent<HTMLInputElement>) { const { name, value } = event.target; switch (name) { case "firstName": setFirstName(value); break; case "email": setEmail(value); break; // … default: // Поле без ветки молча теряется — компилятор не заметит. break; } setIsDirty(true); }
Сам единый обработчик нормален. Проблема в нетипизированной связи между name и состоянием: переименование компилятор не увидит, а default: break скроет пропущенное поле. В v12 switch добавил 8 пунктов к AST-счёту, доведя его до 114.
Универсальный вариант должен принимать keyof FormValues; тогда опечатка станет ошибкой компиляции. Отдельные обработчики тоже остаются простым рабочим выбором.
Карточка стала подтормаживать: при каждом вводе React заново вызывает весь компонент, включая вычисления и построение тяжёлых секций.
«React же сам говорит: мемоизируй. memo на компонент, useCallback на каждый обработчик, useMemo на вычисления — и рендеры прекратятся».
// 😈 v13 — мемоизация поверх бардака export const CustomerCard = memo(function CustomerCard({ /* … */ }) { const handleSave = useCallback(() => { // … }, [customerId, currentUser.role, firstName, lastName, email, phone, city, street, zip, discountDraft, validate]);
memo снаружи не срабатывает: родитель передаёт currentUser={{ name, role }} и создаёт новый объект на каждом рендере. Внутри появился useCallback с одиннадцатью зависимостями, которые теперь надо поддерживать. При этом ввод в поле «Индекс» по-прежнему пересобирает таблицу заказов.
v13 вырос до 579 строк и 34 хуков; восемь новых хуков обслуживают мемоизацию.
Сначала стоит проверить профилировщик и отделить форму от заказов, затем мемоизировать измеренное горячее место. В проектах с React Compiler часть такой мемоизации выполняется автоматически; границы ответственности он за компонент не спроектирует.
На демо кликнули «Экспорт CSV» до загрузки заказов — экран упал.
«Оберни в try/catch и залогируй. Пользователь не должен видеть ошибки, а в консоль никто не смотрит… в смысле, посмотрим, если что».
// 😈 v14 — ошибка обезврежена вместе с сигналом о ней const exportCsv = useCallback(() => { try { const rows = orders!.map(/* … */); // …blob, ссылка, click } catch (cause) { // Что бы ни случилось — не падаем. И никому не говорим. console.error(cause); } }, [orders, customerId]);
Оператор ! лишь отключает проверку TypeScript. Во время выполнения orders всё ещё может быть null, поэтому вызов .map() бросает исключение, а catch его глотает. Пользователь не получает сообщения, мониторинг — события. На v14 это 604 строки и AST-счёт 116.
Кнопку можно отключить до появления данных (disabled={orders === null}), а перехваченную ошибку отразить в интерфейсе или отправить в мониторинг.
Семьсот строк. Распилить — «риск перед релизом».
«Расставь комментарии-баннеры: SECTION: EFFECTS, SECTION: HANDLERS. Утилиты собери в конец файла. И TODO напиши, что распилим после релиза, — сразу видно, что порядок есть».
// 😈 v15 — порядок навели комментариями // ============================================================================= // ВАЖНО: перед изменением файла согласуйте с командой CRM. // TODO: распилить на модули (тикет CRM-1187, заведён 2 года назад). // NOTE: не удаляйте helpers внизу файла — неизвестно, кто их использует. // ============================================================================= // @deprecated Лояльность считает бэкенд с 2024 года. Оставлено «на всякий случай». function calcLoyaltyScore(totalCents: number, ordersCount: number): number { /* … */ }
В v15 — 697 строк, из них 617 строк кода. С прошлого шага добавились 13 строк кода, 32 строки комментариев и 11 пустых. Комментарии описали отсутствующие границы, но не создали их; дальше нужен последовательный распил под поведенческими тестами.
Финал хроники: 35 хуков, 6 пропсов и AST-счёт ветвлений 116. Компонент при этом проходит все сценарии стенда — поэтому подобные файлы могут долго жить без срочного рефакторинга.
Стенд считает метрики всех шестнадцати снапшотов.
AST-счёт ветвлений компонента — авторский сигнал стенда. Для каждой функции расчёт начинается с 1, затем стенд добавляет по одному пункту за:
if, тернарный оператор, case, цикл и catch;
&&, || и ??.
Вложенные функции считаются самостоятельно и входят в обход родительской функции; для шага берётся максимальный результат. ?. балл не добавляет. Это не классическая цикломатическая сложность; реализация лежит в rig/metrics.mjs.
Количество хуков показывает концентрацию состояния и эффектов, но само по себе ничего не говорит о качестве. «Цена монтирования» зависит от форматирования теста и легко уменьшается helper-функцией вроде renderCustomerCard(); здесь она нужна только для сравнения одинаково оформленных снапшотов.

Строки: 123 → 95 (!) → 617. Первые советы код сокращали.

AST-счёт ветвлений: 7 → 116. Пунктир — максимум распиленной версии (19).
Что видно на графиках и не видно в дифах по отдельности:
Строки выросли в 5 раз, AST-счёт — в 16. Ветвления уплотняются быстрее, чем растёт файл.
Деградация начинается со знака минус. v01–v02 сократили код на 28 строк — уничтожение границ выглядит как упрощение, и ревью его пропускает.
Самый крутой участок — не там, где больше всего строк. Форма (v05) дала +157 строк, но +12 к AST-счёту; валидация (v07) — всего +62 строки, зато +26 к счёту. Объём дифа не показывает плотность ветвлений.
Один показатель способен обмануть. На v11 условия в JSX упали с 47 до 37, потому что ветвления спрятались в render-функции; AST-счёт при этом вырос.
Пространство сценариев растёт быстро. Три boolean-пропа уже дают восемь конфигураций. Если добавить три роли, по три состояния двух запросов и режим редактирования, получится до 8 × 3 × 3 × 3 × 2 = 432 теоретических комбинаций. Не все они допустимы, но нескольких happy-path тестов для такого пространства мало.

Deep-link ?step=v15&tab=metrics: финальный шаг и его дельта открываются сразу, без ручного поиска по слайдеру.
Обратный маршрут состоит из небольших шагов с зелёными тестами после каждого. Один общий набор поведенческих тестов подтверждает совпадение v15 и src/good/ в 12 фиксированных публичных сценариях: профиль, роли, маскирование, валидация, серверная 422, скидка, экспорт, компактный и модальный режимы. Он страхует от известных регрессий, но не доказывает эквивалентность для всех возможных входов.
0. Страховка. До любого распила — поведенческие тесты поверх текущего комбайна, через публичный интерфейс (пропы и экран), без знания о внутренностях. Это ключевая страховка, которая позволяет выполнять остальные шаги небольшими безопасными изменениями.
const TARGETS = [ ["комбайн v15", CombineCard], ["распиленная версия", GoodCard] ] as const; describe.each(TARGETS)("CustomerCard (%s)", (_name, Card) => { it("менеджер сохраняет новый email", async () => { render(<Card customerId="c1" currentUser={USERS.manager} />); // один сценарий через публичный интерфейс — для обеих реализаций }); });
1. Слой данных. DTO и fetch уезжают в api/client.ts — единственное место, знающее про snake_case. Компонент получает useCustomer и useOrders. Отменяет советы 01–04.
2. Форма. Значения, ошибки и статус — одна модель на useReducer (form/useContactForm.ts); валидация — чистая validateContacts в домене; серверные ошибки приходят типизированным результатом saveCustomer. Отменяет советы 05–08 и 12.
3. Права. Таблица разрешений и can(role, action) (permissions/permissions.ts) плюс декларативный <Can>. Девятнадцать сравнений строк превращаются в девять строк таблицы. Отменяет совет 09.
4. Композиция. CustomerHeader, CustomerDetails, OrdersSection, ContactForm — части с контрактами; карточка-оркестратор собирает их за сотню строк. Флажки остаются только на внешней границе (ради совместимости тестов), внутри превращаясь в выбор ветки композиции. Отменяет советы 10–11; мемоизация (13) становится ненужной — ввод в форму больше не рендерит заказы; экспорт (14) блокируется до готовности данных.
// src/good/ui/CustomerCard.tsx — оркестратор, а не склад логики const { customer, loading, error, setCustomer } = useCustomer(customerId); const ordersState = useOrders(customerId); return ( <article className="card"> <CustomerHeader customer={customer} role={role} onEdit={startEdit} /> {editMode ? ( <ContactForm customer={customer} role={role} onSaved={setCustomer} /> ) : ( <CustomerDetails customer={customer} role={role} /> )} <Can role={role} do="view-orders"> <OrdersSection customerId={customerId} {...ordersState} role={role} /> </Can> </article> );

Снаружи карточка не стала «архитектурной»: тот же сценарий и тот же экран. Изменились границы, по которым её можно безопасно менять.
Счёт после обезвреживания — из того же report/metrics.json:
Показатель | Комбайн v15 | Распиленная версия |
|---|---|---|
Файлов | 1 | 14 |
Строк кода всего | 617 | 724 |
Самый большой файл | 617 | 97 |
AST-счёт ветвлений | 116 | 19 |
Хуков | 35 | 11 |
Условий в JSX | 35 | 25 |
Качественная оценка автора | несколько несвязанных причин | преимущественно одна причина на модуль |
Строк стало на сотню больше: границы требуют кода. Самый большой файл сократился с 617 до 97 строк, AST-счёт — со 116 до 19, правило скидки оказалось в таблице прав, а валидация теперь тестируется обычным вызовом. Бейдж VIP из вступления добавляется в CustomerHeader.
Рубрика ироничная, правило серьёзное: «никогда не пишите длинных компонентов» — такая же догма, как любая другая.
Страница без переиспользования. Крупный компонент страницы, чьи части нигде больше не нужны, — нормальное состояние. Вводите декомпозицию по мере появления реальной необходимости, а не заранее; подробнее этот выбор я разбирал в статье об архитектурах.
Прототип с коротким сроком жизни. Код на выброс не окупает границ — если он правда будет выброшен.
Форма из трёх полей. Три useState не требуют useReducer; советы 05–08 становятся вредными на масштабе, а не с первого поля.
Оркестратор. Сотня строк композиции тонких детей — это не комбайн, это сборка. Комбайн начинается там, где у файла появляется вторая профессия, а не вторая сотня строк.
Комбайн не приходит одним PR — он приходит фразами в описании. По ним его и ловят.
Фраза-маркер в PR | Что спросить | Какой совет прорастает |
|---|---|---|
«Пока положил рядом, потом вынесем» | Когда «потом» и что помешает сейчас? | 01, 07 |
«Просто ещё один useState» | Меняется ли он согласованно с существующими? | 05, 06 |
«Добавил флажок, чтобы не плодить компоненты» | Какие комбинации флажков осмысленны? | 10 |
«Разбил на renderX для читаемости» | Какой у секции контракт? Что она видит? | 11 |
«Сделал универсальный обработчик» | Что случится при переименовании поля? | 12 |
«Обернул в memo, стало быстрее» | Есть замер? Пропсы стабильны? | 13 |
«Добавил try/catch, чтобы не падало» | Кто узнаёт об ошибке? | 14 |
«Причесал файл, добавил секции» | Почему секция — не файл? | 15 |
Машинная страховка к человеческой: max-lines, max-lines-per-function, complexity в ESLint работают как сигнализация с порогами, которые команда выбрала сознательно. Повторившееся на ревью замечание можно превратить в правило линтера и больше не обсуждать вручную.
Комбайн выращен одним человеком за неделю — намеренно. Настоящие растут годами, руками многих людей и без злого умысла; моя реконструкция воспроизводит механику шагов, но не социальную динамику вокруг них.
Метрики кода — не метрики боли. AST-счёт 116 не означает «в шесть раз больнее», чем 19: время онбординга, страх правок и скорость ревью я не измерял.
Причины изменения — качественная оценка автора. Она подписана так прямо в сравнительной таблице и не смешивается с машинными метриками.
Распиленная версия — один из корректных вариантов, не эталон: те же тесты пройдёт и другая нарезка. Смысл не в конкретных папках, а в том, что каждая часть меняется по одной причине.
Домен синтетический. CRM-карточка выбрана как узнаваемый кейс; в вашем домене шаги деградации будут другими, механика — той же.
Таймлайн деградации: 16 снапшотов, распиленная версия, стенд метрик и playground со слайдером
Thinking in React · Choosing the State Structure · You Might Not Need an Effect
Предыдущие статьи цикла: про useEffect · про any и границу приложения · про TypeScript 7 на Go · про шесть архитектур
Меня зовут Виктор Горбачёв. Больше семи лет пишу фронтенд коммерчески, третий год руковожу командой, преподавал React и TypeScript.
Самый большой компонент, который я встречал на ревью, был на 2400 строк — и у него тоже когда-то была версия v00. Расскажите в комментариях про ваш рекорд: сколько строк, какой продуктовый запрос вырастил его последним — и на каком из пятнадцати советов вы узнали свой проект.
Это первый выпуск рубрики «пошКОДим», в которой антипаттерны разбираются через работающий плохой код.