Skip to content

Hot Module Replacement: How It Works and When It Breaks

A practical HMR mental model for Vite-era SPAs, what updates in place, which breakage patterns waste hours, and when a full reload is the honest fix.

5 min read
HMR
Vite
DX
SPA
Bundler
Frontend

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:

  1. Dev server serves modules over native ESM (or a transform pipeline that behaves like it).
  2. You save a file. The server invalidates that module graph edge.
  3. The browser receives an HMR update for the boundary that accepted the change.
  4. That boundary re-executes and patches exports / React components.
  5. If nothing accepts the update, the client full-reloads.
text
edit 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)

  1. Hard reload. Still wrong? Not HMR, your code or cache.
  2. Fixed by reload, breaks again on save? Module state / cycle / export shape.
  3. Check terminal for HMR errors / "Could not Fast Refresh".
  4. Temporarily disable the change that crosses providers/routers.
  5. pnpm why react / look for duplicate React.
  6. 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.