javascript

пошКОДим: как превратить React-компонент в неуправляемый комбайн — 15 вредных советов

  • воскресенье, 26 июля 2026 г. в 00:00:07
https://habr.com/ru/articles/1061620/
Компонент, который делает всё, невозможно управляемо изменять
Компонент, который делает всё, невозможно управляемо изменять

Комбайн не пишут — его выращивают. По одному разумному шагу за спринт.

На планировании оценивают задачу: «добавить бейдж VIP на карточку клиента». Разработчик открывает CustomerCard.tsx, молчит и говорит: «Дня три». Никто не смеётся — все открывали этот файл. В нём почти семьсот строк, и он умеет всё: грузит клиента и его заказы, кэширует, валидирует форму, различает три роли, экспортирует CSV и не падает. Сцена собирательная, но файл — нет: для этой статьи я вырастил его сам, шаг за шагом, и каждый шаг сохранил.

Вопрос к такому файлу — не «почему он большой?». Вопрос — «сколько у него причин измениться и кто их считает, кроме ревьюера?»

За годы code review я много раз наблюдал рождение комбайна и ни разу — конкретный момент, когда обычный компонент становится неуправляемым. Такого момента нет: каждое отдельное изменение выглядит разумным, укладывается в дедлайн и проходит ревью. Поэтому статья устроена как хроника: один компонент, пятнадцать вредных советов, и после каждого — счёт, который выставляет машина.

Неуправляемость не измеряется строками — строки лишь симптом. Болезнь — число причин изменения, собранных в одном файле. Пока их никто не считает, комбайн растёт легально: каждый его шаг проходит code review.

Курсивом в кавычках говорит «бывалый» с вредным советом. Следовать ему не надо.

Все 16 версий собираются и запускаются. Основной сценарий сохраняется, но по мере деградации появляются воспроизводимые дефекты. После каждого шага стенд печатает набор метрик; часть вычисляется через AST. Формула авторского счёта ветвлений будет рядом с графиками, а числа воспроизводятся командой pnpm report.

Открыть проект с playground и всеми версиями компонента

Точка отсчёта: v00

Карточка клиента в 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 строк.

Запомните эти числа. Дальше они будут только расти. Почти.


Акт I. Всё своё ношу с собой

01. «Зачем тебе отдельный API-слой»

Запрос из спринта: показать карточку клиента. Уже сделано, но на ревью приходит опытный голос:

«Двадцать строк на хук ради одного запроса? Плюс отдельный файл, плюс импорты. Перенеси 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 меняется без правки разметки.

Playground на исходном шаге v00
Playground на исходном шаге v00

Слайдер переключает рабочие снапшоты, диф и измерения; здесь открыта исходная карточка.

02. «Храни как пришло»

Подключили реальный 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.

03. «Заказы? Ещё один fetch рядом»

Запрос: показать историю заказов.

«Второй запрос — второй 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. Комбинации статусов останутся, зато состояние каждого запроса будет собрано в одном месте.

04. «Сам себе кэш»

Пользователи щёлкают по списку клиентов, и каждое переключение — новый запрос.

«Кэш пишется за пять минут: словарик в 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. Четыре совета — и компонент стал владельцем сетевых запросов, формата данных, гонок и кэша. Четыре новые причины изменения, ни одна не про интерфейс. Самое коварное: два первых шага сократили код — граница исчезала под видом упрощения.


Акт II. Форма и правда

05. «На каждое поле — свой useState»

Запрос: редактирование контактов прямо в карточке.

«Семь полей — семь 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, а не из десяти разрозненных переменных.

06. «Производное тоже храни»

Запрос: показать заполненность профиля и сумму заказов.

«Посчитал один раз — сохрани в состояние. Нечего пересчитывать на каждый рендер, это же оптимизация».

// 😈 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 понадобится только при измеренной дорогой операции.

07. «Валидация здесь же»

Запрос: телефон и 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) может быть чистой функцией от значений. Для её теста достаточно обычного вызова с объектом.

08. «Ошибки сервера — в тот же объект»

Сервер тоже валидирует и возвращает 422.

«У тебя уже есть formErrors — мержи туда серверные ошибки, поля-то те же. Пользователю всё равно, кто ошибку нашёл».

// 😈 v08 — две юрисдикции в одном словаре
if (response.status === 422) {
    const payload = await response.json();
    setFormErrors((prev) => ({ ...prev, ...payload.errors }));
    return null;
}

Клиентские ошибки пересчитываются при вводе или submit, серверные появляются позже. После слияния словарь не хранит источник и время жизни записи: ошибка сервера про email может висеть даже после исправления поля.

Серверная ошибка в форме на шаге v08
Серверная ошибка в форме на шаге v08

На экране всё выглядит аккуратно. Проблема спрятана во времени жизни ошибки: после исправления поля её владелец остаётся неясным.

v08 добавил всего 9 строк и 2 пункта к AST-счёту. Метрика почти не изменилась, хотя модель ошибок потеряла важную информацию.

Происхождение и время жизни ошибок нужно отразить в модели: отдельными clientErrors и serverErrors либо типизированным union с полем source.

Итог акта II: компонент теперь хранит черновик, валидирует его и переводит серверные ошибки. AST-счёт вырос с 30 до 74; большая часть новых веток проверяется только через рендер.


Акт III. Все роли, один экран

09. «Права проверяй в JSX»

Запрос: менеджер не должен видеть скидку, наблюдатель — полный телефон и заказы.

«Права — это просто 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-правило в одном месте и позволяют проверить его перебором. Это управляет отображением интерфейса, но не заменяет серверную авторизацию.

10. «Новый вариант — новый boolean-проп»

Карточку захотели в модалке и компактно в списке.

«Не плодить же второй компонент. 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 и отдельные сборки под конкретные контексты. Неиспользуемые комбинации флагов исчезают из публичного контракта.

11. «Не дели на компоненты — дели на renderSection()»

Файл перевалил за 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-счётом.

Если секции дать пропсы и вынести в компонент, у неё появится проверяемый контракт и ограниченный доступ к состоянию.

12. «Свяжи поля магическими строками»

Восемь 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; тогда опечатка станет ошибкой компиляции. Отдельные обработчики тоже остаются простым рабочим выбором.


Акт IV. Косметика вместо хирургии

13. «Тормозит? Оберни всё в memo»

Карточка стала подтормаживать: при каждом вводе 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 часть такой мемоизации выполняется автоматически; границы ответственности он за компонент не спроектирует.

14. «Падает? try/catch и console.error»

На демо кликнули «Экспорт 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}), а перехваченную ошибку отразить в интерфейсе или отправить в мониторинг.

15. «Файл разросся? Наведи порядок комментариями»

Семьсот строк. Распилить — «риск перед релизом».

«Расставь комментарии-баннеры: 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-счёт ветвлений компонента по шагам
AST-счёт ветвлений компонента по шагам

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 тестов для такого пространства мало.

Счётчик деградации на шаге v15
Счётчик деградации на шаге v15

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>
);
Распиленная версия в том же playground
Распиленная версия в том же playground

Снаружи карточка не стала «архитектурной»: тот же сценарий и тот же экран. Изменились границы, по которым её можно безопасно менять.

Счёт после обезвреживания — из того же 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 становятся вредными на масштабе, а не с первого поля.

  • Оркестратор. Сотня строк композиции тонких детей — это не комбайн, это сборка. Комбайн начинается там, где у файла появляется вторая профессия, а не вторая сотня строк.


Чек-лист для code review

Комбайн не приходит одним 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-карточка выбрана как узнаваемый кейс; в вашем домене шаги деградации будут другими, механика — той же.

Репозиторий и источники

Об авторе

Меня зовут Виктор Горбачёв. Больше семи лет пишу фронтенд коммерчески, третий год руковожу командой, преподавал React и TypeScript.

Самый большой компонент, который я встречал на ревью, был на 2400 строк — и у него тоже когда-то была версия v00. Расскажите в комментариях про ваш рекорд: сколько строк, какой продуктовый запрос вырастил его последним — и на каком из пятнадцати советов вы узнали свой проект.

Это первый выпуск рубрики «пошКОДим», в которой антипаттерны разбираются через работающий плохой код.