Fixing Vite HMR Full Reloads from Circular Barrel Imports
Editing one component triggers a full page reload instead of an in-place patch, and the terminal prints [vite] hmr update /src/components/Button.tsx immediately followed by page reload. The usual culprit is a circular dependency introduced by a re-exporting index.ts barrel: the cycle has no clean HMR accept boundary, so Vite gives up and reloads the page. This is a specific failure of the invalidation model described in Optimizing Vite Dev Server and HMR, which is the place to start if the broader update flow is unfamiliar.
Problem Scope & Prerequisites
This covers React or Vue projects on Vite 5.x or 6.x (Node 20+) where a directory index.ts re-exports modules that, directly or transitively, import that same barrel. The fix is independent of framework plugin version. You will need @vitejs/plugin-react 4.x (or @vitejs/plugin-vue 5.x) so Fast Refresh boundaries exist in the first place — a barrel cycle defeats them, which is what we are diagnosing.
Reproducible Repro
Create a barrel and a cycle through it. The app imports Card and Button from the barrel; Card also imports Button from the barrel, closing the loop.
# Vite 6.x, Node 20+
npm create vite@latest barrel-repro -- --template react-ts
cd barrel-repro && npm install
// src/components/index.ts — the barrel
export { Button } from './Button';
export { Card } from './Card';
// src/components/Button.tsx
export function Button({ label }: { label: string }) {
return <button>{label}</button>;
}
// src/components/Card.tsx — imports a sibling THROUGH the barrel, closing the cycle
import { Button } from './index'; // <-- cycle: index.ts -> Card -> index.ts
export function Card() {
return <div className="card"><Button label="ok" /></div>;
}
// src/App.tsx
import { Button, Card } from './components';
export default function App() {
return <main><Card /><Button label="save" /></main>;
}
Run npm run dev, open the app, and edit the text in Button.tsx. Instead of a Fast Refresh, the page reloads.
Diagnosis Workflow
- Read the reload log. With the dev server running, edit
Button.tsxand watch the terminal:
The[vite] hmr update /src/components/Button.tsx [vite] page reload src/components/Button.tsxpage reloadline — nothmr updatealone — is the signal that the accept boundary walk failed. - Turn on HMR debug tracing. Restart with:
You will see the importer walk. A line likevite --debug hmr[vite:hmr] ... is not self-acceptingor a(circular imports)note names the cycle that blocked the boundary. The walk climbsButton → index.ts → Card → index.tsand, finding no self-accepting module, escalates to the entry. - Confirm the cycle independently. Run a static check so you are not guessing:
Expect output listingnpx madge --circular --extensions ts,tsx srccomponents/index.ts > components/Card.tsx > components/index.ts. This is the exact loop Vite cannot break a boundary inside of. - Confirm the barrel is the hub. Grep for who imports the barrel:
grep -rn "from './index'" srcandgrep -rn "from './components'" src. Any in-directory module importing its own barrel is a cycle candidate.
The Fix
The root cause is that Fast Refresh can only set a clean accept boundary on a module whose exports are only components. A barrel re-exports a mix and participates in a cycle, so neither the barrel nor the cyclically-imported component qualifies. Break the cycle by importing siblings deeply (never through the barrel), and, for non-component modules that legitimately need to hot-update, add an explicit import.meta.hot.accept.
// src/components/Card.tsx — FIX: deep import, no barrel, cycle broken
import { Button } from './Button'; // direct path, not './index'
export function Card() {
return <div className="card"><Button label="ok" /></div>;
}
// src/components/index.ts — barrel stays for EXTERNAL consumers only.
// Nothing INSIDE this directory may import from here.
export { Button } from './Button';
export { Card } from './Card';
For a non-component module (a store, a registry) that must survive hot updates without a reload, declare the boundary yourself:
// src/state/registry.ts — Vite 5.x / 6.x
export const registry = new Map<string, unknown>();
if (import.meta.hot) {
// Self-accept so an edit here patches in place instead of reloading,
// and dispose state so the new module does not double-register.
import.meta.hot.accept((mod) => {
if (mod) console.info('[hmr] registry updated');
});
import.meta.hot.dispose(() => registry.clear());
}
The rule to enforce in review: a directory’s index.ts barrel is for consumers outside the directory. Modules inside the directory import each other by relative path. That single convention removes the entire class of barrel-induced HMR cycles.
Verification
Re-run the dev server with tracing and edit Button.tsx again:
vite --debug hmr
Expected output is a single update with no reload:
[vite] hmr update /src/components/Button.tsx
The browser should now apply a Fast Refresh — component state (an open menu, a typed input) survives the edit. Re-run the static check to prove the cycle is gone:
npx madge --circular --extensions ts,tsx src
# Expected: "No circular dependency found!"
In DevTools → Network → WS → Frames, the save should produce a small update JSON frame rather than a navigation entry in the main Network tab.
Gotchas & Edge Cases
- Mixed exports break Fast Refresh even without a cycle. A file exporting both a component and a plain constant or hook can be marked non-self-accepting. Move non-component exports to a separate module; do not co-locate them with the component you want to hot-update.
- Barrels also hurt cold start and tree-shaking. Importing one symbol from a wide barrel pulls the whole
index.tsinto the graph. The dev-server cost shows up as larger HMR payloads; the production cost is dead code. The build-time side is covered in eliminating barrel file side effects in tree-shaking. madgemay miss dynamic cycles. A cycle formed throughimport()will not appear inmadge --circular. Trust thevite --debug hmrwalk in that case; it sees the runtime graph.- TypeScript path aliases hide the barrel. An alias like
@/componentsresolving to the barrel makes deep imports look identical to barrel imports in source. Resolve the alias mentally — or withvite --debug resolve— before concluding an import is “deep”.
Related
- Optimizing Vite Dev Server and HMR — the parent guide on the accept-boundary walk and WebSocket update flow.
- Fixing slow Vite HMR in large monorepos — the other dominant cause of degraded HMR, watcher and pre-bundle overhead.
- Eliminating barrel file side effects in tree-shaking — the production-build cost of the same barrels.