Fix "only exports components": Fast Refresh ESLint error
The exact ESLint rule that throws this, the six code paths that produce six different wordings, and the one move that actually silences it.
TL;DR#
eslint-plugin-react-refresh throws this when a file defines something that looks like a React component (a PascalCase variable, including a lazy(() => import(...)) call) but the file's actual exports are not components — here, an array of route objects. The rule enforces that a file's exports are all components, nothing else, so integrations can safely hot-swap it. The fix is the one in the Stack Overflow question: move the component-shaped locals and the other exports into separate files.
The error#
/app/src/routes.jsx
4:7 error Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option react-refresh/only-export-components
5:7 error Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option react-refresh/only-export-components
✖ 2 problems (2 errors, 0 warnings)That is the current (eslint-plugin-react-refresh 0.5.7) wording, reproduced below. The original question, from November 2023, shows the same rule with a shorter message:
Fast refresh only works when a file only exports components. Move your component(s) to a separate file.eslint(react-refresh/only-export-components)
Both are the same rule, react-refresh/only-export-components, with its extraHOCs hint added in a later release — the wording changed, the cause did not.
Why it happens#
eslint-plugin-react-refresh is the ESLint rule behind this message (npm i -D eslint-plugin-react-refresh, registry.npmjs.org/eslint-plugin-react-refresh). Its own README is direct about why it exists: it "enforce[s] that your components are structured in a way that integrations such as react-refresh expect." The rule itself only checks the shape of a file's exports and locally defined, component-shaped variables — it never runs a bundler or inspects anything at runtime.
The rule's source makes the two failure shapes explicit (reproduced from the installed package, node_modules/eslint-plugin-react-refresh/index.js):
namedExport: "Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.",
localComponents: "Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option.",The question's code hits localComponents: it defines LoginPage and LoginSuccessPage with lazy(() => import(...)), both PascalCase, both recognised by the rule as component-shaped declarations — but the file's only export is authRoutes, an array, not a component. The rule sees component-shaped locals with no matching component export and reports the locals. First answer on the question (sum next to a Component export) hits the other branch, namedExport: here the component is exported, alongside a plain function, so the rule reports the non-component export instead. Same root cause, two different code paths, two different wordings — reproduced in Docker below with both cases.
Next.js's own Fast Refresh architecture page names the identical condition for its own hot-reload mechanism, independent of this ESLint rule: "If you edit a file with exports that aren't React components, Fast Refresh will re-run both that file, and the other files importing it." And further: a file "imported by files outside of the React tree" — exporting something a non-React module also consumes — makes Fast Refresh "fall back to doing a full reload." Next.js's error reference for that fallback lists the same trigger first: "The file you're editing might have other exports in addition to a React component." eslint-plugin-react-refresh ships a configs.next() preset specifically to allow the exports Next.js pages and layouts need (fetchCache, revalidate) without tripping this rule.
Fix#
1. Move the non-component logic out of the file that exports components (or vice versa).
For the question's exact code, move the lazy() calls into their own file:
// src/imports.jsx
import { lazy } from "react";
export const LoginPage = lazy(() => import("./pages/auth/login"));
export const LoginSuccessPage = lazy(() => import("./pages/auth/login/success"));// src/routes.jsx
import { Suspense } from "react";
import Loader from "./loader";
import { LoginPage, LoginSuccessPage } from "./imports";
export const authRoutes = [
{
path: "/login",
element: (
<Suspense fallback={<Loader />}>
<LoginPage />
</Suspense>
),
children: [
{
path: "welcome",
element: (
<Suspense fallback={<Loader />}>
<LoginSuccessPage />
</Suspense>
),
},
],
},
];This passes: routes.jsx no longer defines LoginPage/LoginSuccessPage locally, so the rule has no component-shaped local to flag, and authRoutes staying a non-component export is fine once nothing component-shaped sits next to it in the same file.
2. For the mixed function-and-component case (first answer on the question), split the same way: put sum in a utils.js and keep only the component export in the .jsx file. allowConstantExport (on by default in the Vite preset, confirmed below) does not cover this — it only tolerates exporting constant primitives alongside a component, not functions.
3. If every export genuinely is a higher-order component wrapper, the rule's own hint applies: add the wrapper's name to the extraHOCs option in your ESLint config, instead of moving files around.
Reproducing it#
Confirmed on Node 20.20.2, ESLint 10.13.0, eslint-plugin-react-refresh 0.5.7, the vite() preset, 16 October 2026, in a throwaway node:20-alpine container.
The preset used (reactRefresh.configs.vite().rules) resolves to:
{"react-refresh/only-export-components":["error",{"allowConstantExport":true,"allowCompoundComponents":true}]}allowConstantExport (the README describes it as: "Don't warn when a constant (string, number, boolean, templateLiteral) is exported aside one or more components") is already true here — and the question's file still fails anyway, because that option only concerns a constant export sitting next to a component export. The question's file has no component export at all, only two component-shaped locals (LoginPage, LoginSuccessPage) next to the one export, authRoutes; that is the localComponents branch, which allowConstantExport does not touch. Linting the question's original file layout:
/app/src/routes.jsx
4:7 error Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option react-refresh/only-export-components
5:7 error Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option react-refresh/only-export-components
EXIT=1Linting the fixed layout from the previous section (imports moved out):
### LINT src/routes.fixed.jsx
--- eslint output ---
EXIT=0Negative control — the first answer's mixed file (sum next to a Component export), to show it is the namedExport branch, not localComponents:
/app/src/Utilities.jsx
1:14 error Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components
EXIT=1Verify the fix#
Re-run the lint on the file you changed. A clean exit with no react-refresh/only-export-components line means the rule found no component-shaped local sitting next to a non-component export:
npx eslint src/routes.jsxIf you use eslint.config.js with the flat config, make sure your files glob actually matches the extension you're editing — the vite()/next() presets set no files pattern of their own, so a config built only from one of them lints nothing and reports "File ignored because no matching configuration was supplied" instead of running the rule at all.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
The .ts vs .tsx detail (third answer on the question)#
The third answer on the question says renaming index.tsx to index.ts fixed the error. The rule's own source explains why, without needing to guess at a mechanism: it only ever scans files ending in .jsx or .tsx (plus .js if you opt in with checkJS) —
const shouldScan = filename.endsWith(".jsx") || filename.endsWith(".tsx") || checkJS && filename.endsWith(".js");Renaming to .ts takes the file out of the rule's scan entirely, confirmed by linting the same mixed content as both .ts (silent, exit 0) and .tsx (reported, namedExport, exit 1) in the reproduction above. That is a lint-time silence, not proof that Vite's Fast Refresh handles the file any differently at runtime — this reproduction only covers what the ESLint rule reads, not the bundler's module-invalidation behaviour.
One more thing to check#
If your file exports a default component function (the second answer's Router pattern — a Router() function exported as default, with any other logic kept local and unexported), the rule's internal hasReactExport check is true, so none of the branches above report anything — confirmed the same way in the reproduction (router.jsx, exit 0).
For the Docker file-watching side of hot reload — the container never seeing an edit at all — see React hot reload doesn't work in a Docker container. For the production-only failure mode of a lazy()-loaded chunk after a deploy, see Failed to fetch dynamically imported module (Vite). And for another case where Next.js re-running a non-component module on refresh produces a harder runtime error than a lint warning, see Fix Firestore 'Host has been set in both settings()' error, which cites the same Fast Refresh documentation this page does.
This rule only tells you the file's shape is wrong for Fast Refresh — the terminal output does not name which of the six report branches (namedExport, localComponents, noExport, reactContext, anonymousExport, exportAll) fired, so reading the exact wording is worth the extra ten seconds before reaching for a fix that does not match your actual code.
Further reading: Fix: Do Not Use Array Index in Keys (React). Also see Fix: $VARIABLE is not listed as a dependency in turbo.json.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: Do Not Use Array Index in Keys (React)
Why key={index} breaks on reorder and removal, how to prove it with an uncontrolled input, and the three ways to get a real key when your data has no id.
Fix: $VARIABLE is not listed as a dependency in turbo.json
Declare the variable once, in env or globalEnv, and both the lint error and the silent runtime drop under Strict Mode disappear.
Fix Cannot resolve symbol 'Routes': stale JetBrains typings
Why a JetBrains IDE flags Routes from react-router-dom 6 as unknown while the app builds, how to prove which typings file it reads, and the order of steps that stops the old copy coming back.


