javascript

Тихие сбои React Compiler и как их исправить

  • суббота, 14 февраля 2026 г. в 00:00:07
https://habr.com/ru/companies/otus/articles/996174/

Полагаться на React Compiler означает знать, когда он не срабатывает

Я разрабатываю высокоинтерактивные интерфейсы на React с 2017 года: визуальные редакторы, инструменты для дизайна, приложения, где пользователи перетаскивают элементы, меняют свойства в реальном времени и ожидают, что каждое действие будет отзываться так же быстро, как в Figma или Photoshop. Один лишний ререндер может разрушить иллюзию «прямого управления», из-за чего интерфейс начинает тормозить и раздражать.

Восемь лет я приучал себя думать через useMemo и useCallback. В голове сформировался внутренний «компилятор», который подсвечивал любое значение, способное вызвать лишние ререндеры. Это стало второй натурой.

А потом React Compiler стёр всё это за считанные недели.

Проблема ручной мемоизации

Ручная мемоизация не просто утомляет, это когнитивный налог на каждый компонент, который вы пишете. Приходится постоянно держать в уме:

  • Нужен ли этому обработчику событий useCallback?

  • Нужно ли вынести это в отдельный файл ComponentItem.tsx только ради стабилизации пропсов в .map(...)?

  • Нужно ли «поднять» этот объект стилей или обернуть его в useMemo?

  • Не вызовет ли этот провайдер контекста лишние ререндеры ниже по дереву компонентов?

Ошиблись — либо убили производительность, либо захламили кодовую базу преждевременными оптимизациями. Сделали всё правильно — всё равно потратили когнитивные ресурсы на обвязку вместо продукта.

React Compiler убирает это полностью. Я использую его в продакшене уже полгода. Он стал одним из тех незаменимых инструментов, без которых я больше не представляю работу — как «горячая» замена модулей или автоматические форматтеры кода.

Я больше не думаю о мемоизации. Колеи, накатанные годами привычки, сгладились.

Проблема «тихих» сбоев

Это хорошие новости. Но вот что меня удивило: когда React Compiler не может скомпилировать компонент, он молча откатывается к обычному поведению React.

Философия понятна. Компилятор существует, чтобы делать ваш код лучше, а не чтобы он вообще работал. Если он не может что-то оптимизировать, он откатывается к стандартному поведению React. Приложение продолжает работать.

Но теперь, когда я больше ничего не мемоизирую вручную, стало очевидно, что ручная мемоизация — это форма технического долга. Это лишняя сложность, из-за которой логику компонента труднее читать, а массивы зависимостей превращаются в обузу для поддержки. А в мире с React Compiler это ещё и преждевременная оптимизация, корень всех зол. Я не хочу видеть её в своей кодовой базе.

А значит, теперь я завишу от того, что компилятор успешно обработает некоторые компоненты, особенно те, которые обеспечивают высокочастотные взаимодействия или управляют «тяжёлыми» провайдерами контекста. Если он тихо «провалится» на них, пользовательский опыт ухудшится и даже может полностью сломать некоторые UX-сценарии. Я обнаружил это на анимации «печатной машинки» на главной странице.

Мы отрефакторили её, заменив SSE на обычный fetch, и добавили try/catch с оператором нулевого слияния (??) внутри блока try. Из-за этого код оказался несовместим с React Compiler, и возник странный цикл ререндеров, где ref-колбэк для инпута постоянно «колбасило».

Я понял, что мне нужен способ узнавать, когда компиляция не удалась, и что в таких случаях сборка должна падать.

Недокументированное правило ESLint

Покопавшись в исходниках react-compiler-babel-plugin, я нашёл решение:

    case ErrorCategory.Todo: {
      return {
        category,
        severity: ErrorSeverity.Hint,
        name: 'todo',
        description: 'Unimplemented features',
        preset: LintRulePreset.Off,
      };
    }

Имя правила — todo, поэтому в большинстве конфигураций (если только вы не настроили eslint-plugin-react-hooks с другим именем) полное имя правила будет react-hooks/todo (если плагин подключён под неймспейсом react-hooks). Я нигде не нашёл его в документации (например, в этих ESLint-правилах для React Compiler), но если включить его как ошибку, сборка будет падать на любом компоненте, где используется синтаксис, который компилятор пока не умеет обрабатывать.

После этого в примере с главной страницей такой код:

const handleGeneration = useEffectEvent(async (fetchURL: string) => {
    try {
        const response = await fetch(fetchURL);
        const data = (await response.json()) as { response?: string };
        const finalResult = (data.response ?? '').trim();
        const prompt = getPromptFromResponse(finalResult);
        if (!prompt) {
            handleError();
        } else {
            setPromptSuggestion(prompt);
            setEventSourceURL('');
        }
    } catch (error) {
        logError('Home fetch error', error);
    }
});

приводит к такой lint-ошибке:

/outlyne/app/components/Home.tsx
  86:34  error  Todo: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement

/outlyne/app/components/Home.tsx:86:34
  84 |             const response = await fetch(fetchURL);
  85 |             const data = (await response.json()) as { response?: string };
> 86 |             const finalResult = (data.response ?? '').trim();
     |                                  ^^^^ Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement
  87 |             const prompt = getPromptFromResponse(finalResult);
  88 |             if (!prompt) {
  89 |                 handleError();

Вот как это настроить:

import reactHooks from 'eslint-plugin-react-hooks';

export default [
    {
        files: ['**/*.{js,jsx,ts,tsx}'],
        plugins: { 'react-hooks': reactHooks },
        // ...
        rules: {
            // разворачиваем пресет, чтобы не перезаписать его конкретными правилами ниже
            ...reactHooks.configs.recommended.rules,
            // https://github.com/facebook/react/blob/3640f38/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts#L807-L1111
            'react-hooks/todo': 'error',
            // другие полезные правила:
            'react-hooks/capitalized-calls': 'error', // не вызывайте функции с заглавной буквы (нужно использовать JSX)
            'react-hooks/hooks': 'error', // во многом пере-реализует некомпиляторное правило «rules-of-hooks»
            'react-hooks/rule-suppression': 'error', // проверяет корректность подавления других правил
            'react-hooks/syntax': 'error', // проверяет некорректный синтаксис
            'react-hooks/unsupported-syntax': 'error', // по умолчанию `warn`, используйте `error`, чтобы ронять сборку
            // ...
        },
    },
];

​​Включите это — и удивитесь, сколько компонентов «падают». Пока я не выучил паттерны, которые React Compiler пока не поддерживает, у меня было больше сотни компонентов, которые не удавалось скомпилировать.

Что ломает компилятор

Самый частый неподдерживаемый паттерн, с которым я столкнулся: деструктурировать пропсы и затем мутировать их.

Такой код ломает компиляцию:

function MyComponent({ value }) {
    // Если value не задан, берём значение из состояния
    value = value ?? someStateValue;

    // Или нормализуем значение
    value = normalizeValue(value);

    // Используем value...
}

К счастью, исправление простое и, пожалуй, даже лучше: просто создайте новую переменную, чтобы не мутировать деструктурированные пропсы:

function MyComponent({ value: valueFromProps }) {
    const value = valueFromProps ?? someStateValue;
    // Используем value...
}

Ещё одно ограничение: блоки try/catch с любой нетривиальной логикой. Если в компоненте есть асинхронная работа с try/catch, нельзя использовать:

  • Условия внутри блока try или catch

  • Тернарные операторы, опциональную цепочку илиили оператор нулевого слияния (??)

  • Оператор throw

Часть про «никаких условий» — это настоящая боль. Чаще всего, когда у меня есть компонент, который делает что-то, что может выбросить исключение, у меня есть какая-то условная логика либо в блоке try, либо в catch.

try {
    const response = await fetch(url);
    if (response.ok) {
        // Ломает компиляцию
        setResponse(await response.json());
    } else {
        setError(`Error ${response.status}`);
    }
} catch (error) {
    setError(`${error}`);
}

Формально это всё временные ограничения — на это намекают и название («todo»), и описание («Нереализованные возможности») у lint-правила. Я уверен, что большинство, если не все, из них со временем будут устранены. Хотя стоит упомянуть, что перед todo-ошибкой Support ThrowStatement inside of try/catch в коде стоит такой комментарий:

/*
 * ПРИМЕЧАНИЕ: мы могли бы это поддержать, но использование конструкции `throw` внутри `try/catch` — это использование исключений
* для управления потоком выполнения и обычно считается антипаттерном. Мы, вероятно, можем
* просто не поддерживать этот шаблон, если только это действительно не станет необходимым по какой-либо причине.

*/

Так что, возможно, не все? Иронично, но ошибку Support value blocks… я обходил, полагаясь на небезопасный доступ к свойствам внутри блока try, фактически неявно рассчитывая на выброшенное исключение как на механизм управления потоком.

Как бы то ни было, пока что я поймал себя на том, что начинаю запоминать эти ограничения — примерно так же, как раньше запоминал «лучшие практики» оптимизации, чтобы не допускать лишних ререндеров в React. Это точно не тот результат, который мне нужен.

Используйте линтинг

Вот почему ESLint-правило настолько ценно: оно избавляет от необходимости держать в голове все эти паттерны. Но в некоторых компонентах используются приёмы, которые я не готов усложнять только ради того, чтобы умилостивить компилятор.

Для таких случаев я явно отключаю это правило:

/* eslint-disable react-hooks/todo */
function NonCriticalPathComponent() {
    // Этот компонент необязательно компилировать, чтобы приложение работало быстро,
    // и я не готов рефакторить логику try/catch
}

Такой подход даёт мне лучшее из двух миров:

  • Критичные компоненты обязаны компилироваться, иначе сборка падает

  • Некритичные компоненты могут использовать любые приёмы, которые делают код понятнее

  • Я вообще не думаю о мемоизации

Стоит ли использовать React Compiler?

Однозначно да. Особенно если вы делаете интерактивные интерфейсы, где важна производительность. Одна только когнитивная разгрузка этого стоит.

Но заходите с пониманием, что по умолчанию компиляция будет молча откатываться к обычному поведению React. Если у вас есть критичные участки, где компоненты должны компилироваться (и тем самым быть нормально мемоизированы), настройте ESLint-правило и пусть сборка падает. А дальше принимайте осознанные решения, каким компонентам нужна компиляция, а каким нет.

Ограничения временные. Изменение того, как вы строите UI, — надолго.

Если хотите расти дальше «магии хуков» и понимать, где на самом деле рождаются лишние ререндеры, полезно прокачать базу фронтенда системно. На курсе React.js Developer разбирают production-SPA: TypeScript, Redux (Saga/Thunk), тесты, GraphQL и сборку (Webpack/Babel). Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.

А чтобы узнать больше о формате обучения и задать вопросы экспертам, приходите на бесплатные демо-уроки:

  • 17 февраля 20:00. «Custom Hooks в React — как выносить логику и переиспользовать код». Записаться

  • 5 марта 20:00. «Как создавать реальные React-приложения: от компонента до архитектуры». Записаться

  • 19 марта 20:00. «React и графические библиотеки: визуализация данных». Записаться