Sentry catches what escapes to production. Hot Module Replacement (HMR) is the local loop that keeps most mistakes from getting there, until it lies to you.
When HMR works, you edit a module and the running app patches itself without losing state. When it breaks, you chase ghosts: "I changed the code and nothing happened," or worse, "the UI shows a mix of old and new modules." This post is the mental model I use to stop guessing.
Mental model
Rough Vite-era flow:
- Dev server serves modules over native ESM (or a transform pipeline that behaves like it).
- You save a file. The server invalidates that module graph edge.
- The browser receives an HMR update for the boundary that accepted the change.
- That boundary re-executes and patches exports / React components.
- If nothing accepts the update, the client full-reloads.
textedit module → invalidate importers → find nearest HMR boundary (import.meta.hot.accept) → apply patch → else reload page
React Fast Refresh is a specialized HMR boundary: it remounts or patches components while trying to keep state in components that still match. It is not magic, it has rules.
What usually hot-swaps cleanly
- Pure presentational components (same exports, same hooks order)
- CSS modules / Tailwind-ish class edits (style pipeline updates)
- Leaf utilities with no module-level mutable state
tsx// Leaf component: Fast Refresh happy path export function Badge({ label }: { label: string }) { return <span className="badge">{label}</span>; }
When HMR breaks (the expensive patterns)
1. Module-level mutable state
ts// src/lib/store.ts: HMR will keep the old singleton unless you dispose let listeners: Array<() => void> = []; export function subscribe(fn: () => void) { listeners.push(fn); return () => { listeners = listeners.filter((l) => l !== fn); }; } if (import.meta.hot) { import.meta.hot.dispose(() => { listeners = []; }); import.meta.hot.accept(); }
Without dispose / accept, you accumulate duplicate listeners or keep stale
closures. Symptoms: handlers fire twice, "ghost" subscriptions, memory climbing
in long dev sessions.
2. Export shape changes
You renamed or removed an export. Importers still hold the old binding until a reload. Fast Refresh often gives up and reloads, if it does not, you get runtime "X is not a function" with a stack that looks like your new code.
Fix: full reload once. If it keeps happening, check for circular imports.
3. Circular dependencies
A ↔ B cycles make invalidation order undefined. HMR applies half a graph. You see "impossible" undefined exports that vanish after reload.
Fix: break the cycle (extract shared types/constants to a third module). Treat recurring HMR weirdness as a cycle detector.
4. Non-component files in the React tree without a boundary
Editing context.tsx, route config objects, or createBrowserRouter(...) at
module scope often forces reload, or updates without remounting providers the
way you expect. Provider values stuck on old references are a classic.
5. Side effects in module scope that must run once
ts// Runs on first import: HMR may re-run or skip depending on acceptance if (typeof window !== 'undefined') { window.addEventListener('online', onOnline); }
Pair every module-scope subscription with import.meta.hot.dispose.
6. Env / config changes
Changing .env is not a component edit. Vite restarts or needs a manual reload.
Expecting HMR to pick up new VITE_* values mid-session is a category error
(see the env-vars post).
7. Two copies of React / mismatched Fast Refresh
Monorepo linking gone wrong: duplicate react in the graph. Hooks blow up, or
Refresh silently fails. pnpm why react is the debugging move, not more HMR
config.
A small boundary you control
When you own a Vite plugin-ish module or a vanilla ESM island:
ts// src/feature/counter.ts let count = 0; export function getCount() { return count; } export function increment() { count += 1; return count; } if (import.meta.hot) { import.meta.hot.accept((newMod) => { // Optional: migrate state from previous module instance if (newMod) { // newMod.getCount?.() } }); import.meta.hot.dispose((data) => { data.count = count; }); }
Most app code should lean on the framework's Refresh runtime instead of
hand-rolled accept. Hand boundaries are for non-React modules and libraries.
Debugging checklist (fifteen minutes, not three hours)
- Hard reload. Still wrong? Not HMR, your code or cache.
- Fixed by reload, breaks again on save? Module state / cycle / export shape.
- Check terminal for HMR errors / "Could not Fast Refresh".
- Temporarily disable the change that crosses providers/routers.
pnpm why react/ look for duplicate React.- Clear Vite cache (
node_modules/.vite) if transforms look stuck.
Continuity
HMR sits between "I typed it" and "Sentry never saw it." When the loop lies, full reload is professionalism, not defeat. CSS architecture is where HMR usually does work and the real risk is organizing styles so the app stays coherent.
Takeaway
HMR patches modules at a boundary; it is not a time machine. Module-level state, cycles, and export-shape changes are the usual liars, dispose/accept carefully, reload without shame, and treat recurring weirdness as a graph smell.