Почему ESLint ругается Fast refresh only exports components
Разбор правила react-refresh/only-export-components из eslint-plugin-react-refresh: почему срабатывает на неэкспортируемых компонентах в .tsx файлах, примеры FAIL/PASS, false positives, опции allowConstantExport и workarounds для Vite/Next.js.
Почему ESLint выдает предупреждение «Fast refresh only works when a file only exports components» в файле, который экспортирует только функции и переменные, но содержит неэкспортируемый компонент? Это ложное срабатывание или нужно выносить внутренние компоненты в отдельный файл?
Пример кода:
function Component() {
return <div />;
}
export const stuff = {
render: () => <Component />;
};
Правило react-refresh/only-export-components из eslint-plugin-react-refresh срабатывает на файлах с расширением .tsx. Нельзя ли избежать этого, если компоненты объявлены внутри файла?
Кроме того, в документации приведен пример, помеченный как FAIL, хотя там нет экспортов:
const App = () => {};
createRoot(document.getElementById("root")).render(<App />);
Почему этот пример считается нарушением правила?
Правило react-refresh/only-export-components из eslint-plugin-react-refresh срабатывает на файлах .tsx, потому что обнаруживает React-компоненты (даже внутренние, неэкспортируемые), но видит экспорты non-components вроде объектов или функций — это не ложное срабатывание, а защита от проблем с react refresh (Fast Refresh). В вашем примере Component внутри файла триггерит правило, несмотря на отсутствие его экспорта, так как eslint react refresh требует, чтобы весь файл был “чистым” для компонентов. Пример из документации с createRoot и локальным App тоже FAIL, потому что файл содержит компонент, но не экспортирует его как основной — Fast Refresh ожидает строго компонентные модули.
Содержание
- Что такое react refresh и правило eslint react refresh/only-export-components
- Почему срабатывает предупреждение на неэкспортируемых компонентах
- Примеры FAIL и PASS из документации react refresh plugin
- False positives и быстрые workarounds для eslint plugin react refresh
- Настройка опций: allowConstantExport и allowExportNames
- Лучшие практики рефакторинга для Vite, Next.js и Webpack
- Источники
- Заключение
Что такое react refresh и правило eslint react refresh/only-export-components
React refresh, или Fast Refresh, — это суперудобная фича для разработки React-приложений в Vite, Webpack или Next.js. Она обновляет компоненты на лету, без полной перезагрузки страницы, сохраняя состояние. Но работает идеально только в “чистых” файлах, где экспортируется ровно один компонент или несколько, но без лишнего: никаких утилит, констант или объектов.
Здесь на сцену выходит правило react-refresh/only-export-components из пакета eslint-plugin-react-refresh. Автор плагина, Arnaud Barre (мейнтейнер Vite React-плагинов), создал его специально для bundler’ов вроде Vite. Оно сканирует .tsx/.jsx файлы и ругается, если находит React-компоненты (PascalCase + JSX/TSX), но экспорты — не компоненты. Почему? Fast Refresh HMR (Hot Module Replacement) может сломаться на mixed exports: компонент обновится криво, если рядом utils.
В вашем случае файл экспортирует stuff (объект с рендером), а внутри Component. Правило видит компонент — и бац, предупреждение “Fast refresh only works when a file only exports components”. Это не баг ESLint, а сознательный дизайн: лучше предупредить заранее, чем дебажить HMR в продакшене.
А представьте: вы пишете entry-point вроде main.tsx с createRoot, и там локальный компонент. Fast Refresh подумает, что это модуль для hot-reload, но без экспорта — хаос. Именно поэтому документация метит такие примеры FAIL.
Почему срабатывает предупреждение на неэкспортируемых компонентах
Коротко: правило не смотрит, экспортируете ли вы компонент. Оно проверяет наличие любых компонентов в файле. Если они есть (по сигнатуре: uppercase имя + JSX), то все экспорты должны быть компонентами. Ваш код:
function Component() { // ← Это компонент! PascalCase + JSX внутри
return <div />;
}
export const stuff = { // ← Non-component экспорт
render: () => <Component />;
};
Триггер на Component, потому что stuff — объект. Даже если Component приватный, ESLint парсит весь AST. Это не false positive: документация объясняет, что Fast Refresh оптимизирован под “component-only modules”. Смешанные файлы приводят к partial updates, где состояние теряется или UI мерцает.
Теперь про пример из доки:
const App = () => {}; // Компонент!
createRoot(document.getElementById("root")).render(<App />);
Нет экспортов совсем! Но FAIL. Почему? Файл содержит компонент, но не экспортирует его. Правило предполагает: если компонент есть, файл должен быть для HMR — экспортируйте его, или выносите в отдельный модуль. Без экспорта Fast Refresh игнорирует файл, но ESLint предупреждает о потенциальной проблеме. В реале такие entry-points (main.tsx) часто вызывают issues в Vite+React.
Выносить внутренние компоненты обязательно? Не всегда. Но для стабильного react fast refresh — да, рекомендуется. Или используйте опции/дизейбл.
Примеры FAIL и PASS из документации react refresh plugin
Документация eslint-plugin-react-refresh полна кодов. Разберём ключевые.
FAIL (как ваш):
const MyComponent = () => <div />; // Компонент
export const utils = { fn: () => {} }; // Non-component
Или без экспортов:
const App = () => <div />;
createRoot(...).render(<App />); // FAIL!
PASS варианты:
- Только компонент:
export const MyComponent = () => <div />;
- Несколько компонентов:
export const A = () => <div />;
export const B = () => <div />;
- Импорт извне:
import { MyComponent } from './other';
createRoot(...).render(<MyComponent />);
Секрет в сигнатуре: правило детектит функции/классы с JSX и PascalCase. Class components? Поддержка слабая, если нет direct export. export * тоже не всегда работает.
В вашем stuff.render — JSX рендерит Component, но stuff сам не компонент. Отсюда warning.
False positives и быстрые workarounds для eslint plugin react refresh
Да, бывают false positives. Классика из issue #25: React.lazy + роутеры.
const LazyComp = React.lazy(() => import('./Comp.tsx'));
export const router = createBrowserRouter([...]); // Non-component!
Тут warning, хотя lazy — динамика, и react refresh webpack plugin (или Vite) справляется. Ещё: ALL-UPPERCASE константы, конфиги.
Workarounds (без вреда HMR):
- Дизейбл на файле:
/* eslint-disable react-refresh/only-export-components */
export const stuff = { ... };
Автор плагина рекомендует для lazy/router. Не ломает react refresh.
- Inline дизейбл:
// eslint-disable-next-line react-refresh/only-export-components
export const stuff = { ... };
- Wrapper-компонент:
export const StuffWrapper = () => {
const stuff = { render: () => <Component /> };
return null; // Или используйте
};
Но это костыль.
SO-пост от xNevrroXx подтверждает: disable безопасен для mixed файлов.
Настройка опций: allowConstantExport и allowExportNames
Не хотите дизейблить? Конфиг в .eslintrc:
{
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": ["error", {
"allowConstantExport": true, // Разрешает const exports (Vite-friendly)
"allowExportNames": ["router", "stuff"] // Белый список имён
}]
}
}
allowConstantExport: true— для констант вроде вашегоstuff. Идеально для Vite.allowExportNames— для фреймворков (Next.js routers).
В доке: опции эволюционировали, проверяйте версию. Для eslint react refresh vite — добавьте в vite.config.ts:
import reactRefresh from '@vitejs/plugin-react';
export default { plugins: [reactRefresh()] };
Webpack? Аналогично с react-refresh-webpack-plugin.
Лучшие практики рефакторинга для Vite, Next.js и Webpack
Идеал: разделяйте файлы.
- Компоненты — отдельно:
Component.tsx→export const Component = ... - Utils/entry — без JSX:
main.tsxтолькоcreateRoot(<App />)с импортом.
Рефакторинг вашего примера:
// Component.tsx
export function Component() { return <div />; }
// stuff.ts (не .tsx!)
export const stuff = {
render: () => <Component /> // ESLint не парсит JSX здесь
};
Для роутеров (React Router/Next): выносите в routes.tsx с wrapper’ом или опциями.
Vite/Next.js: правило must-have для DX. Webpack с react refresh webpack plugin — то же.
А если лень? Дизейбл + опции. Но чистые модули = стабильный Fast Refresh. В проде это спасает часы дебага.
Источники
- eslint-plugin-react-refresh README — Полная документация правила react-refresh/only-export-components с примерами FAIL/PASS: https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/README.md
- Issue #25: React.lazy false positive — Обсуждение ложных срабатываний с lazy-импортами и роутерами в Vite: https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/25
- Stack Overflow: Avoid ESLint Fast Refresh warning — Практические решения для файлов с non-component экспортами: https://stackoverflow.com/questions/77365777/how-to-avoid-eslint-warning-in-react-fast-refresh-only-works-when-a-file-only-e
Заключение
Правило react-refresh/only-export-components — не прихоть, а гарант стабильного react refresh в eslint react refresh экосистеме: оно ловит mixed файлы до того, как HMR сломается. Ваш случай и док-пример FAIL из-за локальных компонентов без чистого экспорта — выносите в отдельные файлы или настраивайте allowConstantExport/дизейбл. Лучшая практика: компоненты отдельно, utils без JSX. Это ускорит разработку в Vite/Next.js, сэкономит нервы. Попробуйте опции — и warning уйдёт без костылей.
Правило react-refresh/only-export-components из eslint-plugin-react-refresh обеспечивает правильную структуру файлов для React Fast Refresh в сборщиках вроде Vite или Webpack. Оно применяется только к файлам .tsx/.jsx, чтобы избежать ложных срабатываний. Предупреждение возникает, если файл содержит компоненты (PascalCase + JSX), но экспортирует некомпоненты (константы, утилиты, объекты) — даже неэкспортируемые компоненты блокируют, как в FAIL-примере с const App и createRoot.render(<App />), поскольку файл не экспортирует ТОЛЬКО компоненты.
PASS-случаи: экспорт только компонентов или импорт извне. Опции конфигурации: allowConstantExport для констант (Vite), allowExportNames для фреймворков вроде React Router.
Это false positive правила eslint react refresh в файлах с React.lazy и экспортом router (некомпоненты). Автор плагина подтверждает проблему в issue #25. Workarounds:
/* eslint-disable react-refresh/only-export-components */для файла (не ломает react fast refresh);- Экспортировать wrapper-компонент вместо router.
Не выносите внутренние компоненты — disable безопасен. Пример: lazy(() => import("./Component.tsx")) + export {router} триггерит на .tsx.
В issue #25 обсуждается false positive react refresh plugin при React.lazy в Vite + React + TS: предупреждение на файлах с lazy-импортами компонентов и non-component экспортами (routers). Автор eslint plugin react refresh рекомендует ESLint-disable для таких файлов или рефакторинг на экспорт компонента-обертки. Не влияет на react fast refresh. Ограничения правила: нет поддержки export *, class components, ALL-UPPERCASE без прямого экспорта.