Vite 06 ⚡ Hot Module Replacement — How HMR works without full reloads
Vite’s Hot Module Replacement is not a faster version of a full page reload. It is a different mechanism entirely. When a file changes, Vite does not rebuild a bundle and tell the browser to refresh. It sends a WebSocket message to the browser, the browser requests the changed module over native ESM, and the module’s own code decides how to apply the update. The page state—form inputs, scroll position, component state—survives because nothing reloads. Only the changed module is replaced.
This chapter covers how HMR works in Vite: the native ESM foundation that makes module-level updates possible, the import.meta.hot API that defines an HMR boundary, the accept and dispose methods that control what happens when a module is replaced, and the framework plugins that make HMR work for React and Vue without manual import.meta.hot calls in application code.
Key point: HMR requires a boundary. A module that calls import.meta.hot.accept() is an HMR boundary; the update stops there. If no module in the import chain accepts the update, Vite falls back to a full page reload.
Why Vite’s HMR is different
The bundle-based problem. In a bundler-based dev server, the entire application is bundled before it can be served. When a file changes, the bundler must rebuild the bundle, and the browser must reload the page to see the change. The rebuild time grows with the application size, and the reload loses all state .
The ESM solution. Vite serves source code over native ESM. The browser requests each module as it needs it, and Vite transforms the file on demand. When a file changes, Vite does not rebuild a bundle. It invalidates the changed module and sends a WebSocket message to the browser. The browser requests the new version of the module over the existing ESM connection .
The state preservation problem. A full reload destroys the application state. HMR preserves it by replacing only the module that changed, not the entire page. The module’s importers are notified, and if they have declared themselves as an HMR boundary, they apply the update without a reload. If no boundary exists, the page reloads.
a. The HMR boundary and import.meta.hot
A module becomes an HMR boundary when it calls import.meta.hot.accept(). The import.meta.hot object is only present in development; in production, it is undefined, and the HMR code is tree-shaken out.
// counter.js
export let count = 1
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (newModule) {
count = newModule.count
}
})
}
The accept callback receives the updated module. The module that called accept is responsible for applying the new values. Vite’s HMR does not swap the original module reference; the boundary module must update its own exports and any re-exports it is responsible for. The documentation notes that this simplified implementation skips the expensive work of generating proxy modules .
A module can also accept updates from its dependencies without reloading itself. This is how a parent component can accept a change to a child module:
import { foo } from './foo.js'
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
newFoo?.foo()
})
}
The accept method can take an array of dependency paths and a callback that receives the updated modules .
b. dispose and data
When a module is replaced, its old instance may have created side effects: event listeners, timers, subscriptions. The dispose method registers a callback that runs before the module is replaced, allowing the old instance to clean up.
if (import.meta.hot) {
import.meta.hot.dispose((data) => {
clearInterval(timer)
})
}
The data argument is the module’s persistent data object, accessible as import.meta.hot.data. It persists across module replacements, which allows the old instance to pass information to the new one. Mutating properties of data is supported; reassigning data itself is not .
c. Framework plugins and automatic HMR
Application code rarely calls import.meta.hot.accept() directly. The framework plugins handle it. For React, @vitejs/plugin-react integrates React Fast Refresh. The plugin injects the runtime that registers components, and when a component file changes, the plugin’s HMR boundary applies the update while preserving component state .
For Vue, @vitejs/plugin-vue compiles the single-file component into separate virtual modules for script, template, and style. The plugin’s HMR boundary updates the component without reloading the page, and the style module injects the new CSS into the document .
The manual API exists for framework authors and for the rare case where an application module needs custom HMR behavior. For most projects, the framework plugin is the boundary, and the application code does not need to know that HMR exists.
Complete Example Session
// ============================================
// PART 1: A NON-BOUNDARY MODULE
// ============================================
// utils.js
export function add(a, b) {
return a + b
}
// Changing this file triggers a full reload
// because no module in the import chain accepts it.
// ============================================
// PART 2: A SELF-ACCEPTING MODULE
// ============================================
// counter.js
export let count = 1
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (newModule) {
count = newModule.count
}
})
}
// This module is an HMR boundary.
// Changing it updates count without a reload.
// ============================================
// PART 3: THE CONDITIONAL GUARD
// ============================================
if (import.meta.hot) {
// HMR code here
}
// In production, import.meta.hot is undefined.
// The entire block is tree-shaken out.
// ============================================
// PART 4: ACCEPTING A DEPENDENCY
// ============================================
// parent.js
import { foo } from './foo.js'
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => {
newFoo?.foo()
})
}
// parent.js is the boundary for changes to foo.js.
// foo.js does not need its own accept call.
// ============================================
// PART 5: ACCEPTING MULTIPLE DEPENDENCIES
// ============================================
if (import.meta.hot) {
import.meta.hot.accept(['./foo.js', './bar.js'], ([newFoo, newBar]) => {
newFoo?.foo()
newBar?.bar()
})
}
// ============================================
// PART 6: DISPOSE
// ============================================
let timer
function startTimer() {
timer = setInterval(() => {}, 1000)
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
clearInterval(timer)
})
}
// The old module cleans up before the new one loads.
// ============================================
// PART 7: PERSISTENT DATA
// ============================================
if (import.meta.hot) {
const previous = import.meta.hot.data.previousCount
import.meta.hot.data.previousCount = count
console.log('Previous count was', previous)
}
// data persists across module replacements.
// ============================================
// PART 8: REACT FAST REFRESH (PLUGIN)
// ============================================
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
// The plugin handles HMR for React components.
// No manual import.meta.hot calls needed.
// ============================================
// PART 9: VUE SFC HMR (PLUGIN)
// ============================================
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
// The plugin compiles .vue files and handles HMR.
// Script, template, and style are updated separately.
// ============================================
// PART 10: THE FALLBACK
// ============================================
// If no module accepts the update:
// Vite sends a full-reload message.
// The browser reloads the page.
//
// This is the correct behavior.
// HMR is an optimization, not a requirement.
The ten parts covered the non-boundary module, the self-accepting module, the conditional guard, dependency acceptance, multiple dependencies, dispose, persistent data, React Fast Refresh, Vue SFC HMR, and the full-reload fallback.
Quick Reference
HMR API methods
| Method | Purpose |
|---|---|
import.meta.hot.accept(cb) | Self-accept updates |
import.meta.hot.accept(dep, cb) | Accept updates from a dependency |
import.meta.hot.accept(deps[], cb) | Accept updates from multiple dependencies |
import.meta.hot.dispose(cb) | Clean up before replacement |
import.meta.hot.data | Persistent data across replacements |
import.meta.hot.invalidate() | Force a full reload |
HMR boundaries
| Boundary | Defined by |
|---|---|
| Self-accepting module | import.meta.hot.accept() |
| Dependency-accepting module | import.meta.hot.accept('./dep.js', cb) |
| Framework component | Plugin (React Fast Refresh, Vue SFC) |
| No boundary | Full page reload |
Framework plugins
| Framework | Plugin | HMR mechanism |
|---|---|---|
| React | @vitejs/plugin-react | React Fast Refresh |
| React (SWC) | @vitejs/plugin-react-swc | React Fast Refresh |
| Vue | @vitejs/plugin-vue | SFC virtual modules |
Production behavior
| Development | Production |
|---|---|
import.meta.hot exists | import.meta.hot is undefined |
| HMR code runs | HMR code is tree-shaken |
| WebSocket updates | No WebSocket |
Best Practices
✅ Do This:
// Guard all HMR code with the conditional
if (import.meta.hot) { ... } # ✅
// Use framework plugins for component HMR
plugins: [react()] # ✅
// Clean up side effects in dispose
import.meta.hot.dispose(() => clearInterval(timer)) # ✅
// Use data for state that must survive replacement
import.meta.hot.data.cache # ✅
❌ Don’t Do This:
// Call import.meta.hot without a guard
import.meta.hot.accept() // in production # ❌ (undefined)
// Manually handle component HMR when a plugin exists
// The plugin already does it # ❌
// Assume HMR always works
// No boundary = full reload # ❌
// Forget dispose for timers and listeners
// The old instance leaks # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| HMR not working | No module accepts the update | Add import.meta.hot.accept() or use a framework plugin |
| Full reload on every change | The changed file has no boundary | Check the import chain; add a boundary at the right level |
| State resets | The boundary is above the stateful component | Move the boundary down |
import.meta.hot is undefined | Production build | Expected; guard the code |
| Side effects duplicate | No dispose cleanup | Add import.meta.hot.dispose() |
Real-World Examples
1. Self-accepting module
if (import.meta.hot) {
import.meta.hot.accept((newModule) => { ... })
}
2. Accept a dependency
if (import.meta.hot) {
import.meta.hot.accept('./foo.js', (newFoo) => { newFoo?.foo() })
}
3. Dispose cleanup
if (import.meta.hot) {
import.meta.hot.dispose(() => clearInterval(timer))
}
4. Persistent data
if (import.meta.hot) {
import.meta.hot.data.count = count
}
5. React plugin
import react from '@vitejs/plugin-react'
plugins: [react()]
6. Vue plugin
import vue from '@vitejs/plugin-vue'
plugins: [vue()]
7. Force reload
import.meta.hot.invalidate()
8. Conditional guard
if (import.meta.hot) { ... }
9. Accept multiple deps
import.meta.hot.accept(['./a.js', './b.js'], ([a, b]) => { ... })
10. CSS HMR
import './style.css' // injected with HMR support
Visual
The HMR flow
┌─────────────────────────────────────────────────────────────┐
│ HMR FLOW │
│ │
│ File changes on disk │
│ │ │
│ ▼ │
│ Vite detects change │
│ │ │
│ ▼ │
│ WebSocket message to browser │
│ │ │
│ ▼ │
│ Browser requests changed module over ESM │
│ │ │
│ ▼ │
│ Vite transforms and serves the module │
│ │ │
│ ▼ │
│ Module's accept callback runs │
│ │ │
│ ├── boundary exists ──► update without reload │
│ │ │
│ └── no boundary ──► full page reload │
└─────────────────────────────────────────────────────────────┘
The boundary chain
┌─────────────────────────────────────────────────────────────┐
│ BOUNDARY CHAIN │
│ │
│ app.js │
│ └── imports page.js │
│ └── imports component.js ← changed │
│ │
│ If component.js accepts: │
│ update stops at component.js │
│ │
│ If component.js does not accept, but page.js accepts it: │
│ update stops at page.js │
│ │
│ If no module accepts: │
│ full reload │
└─────────────────────────────────────────────────────────────┘
Development vs production
┌─────────────────────────────────────────────────────────────┐
│ DEVELOPMENT PRODUCTION │
│ │
│ import.meta.hot exists import.meta.hot undefined │
│ HMR code runs HMR code tree-shaken │
│ WebSocket connection No WebSocket │
│ Module-level updates Bundled and optimized │
└─────────────────────────────────────────────────────────────┘
Framework HMR
┌─────────────────────────────────────────────────────────────┐
│ FRAMEWORK HMR │
│ │
│ React: │
│ @vitejs/plugin-react │
│ └── React Fast Refresh │
│ └── preserves component state │
│ │
│ Vue: │
│ @vitejs/plugin-vue │
│ └── SFC virtual modules │
│ └── script/template/style updated separately │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Foundation | Native ESM, on-demand module serving |
| Client API | import.meta.hot |
| Boundary | import.meta.hot.accept() |
| Cleanup | import.meta.hot.dispose() |
| Persistent data | import.meta.hot.data |
| Force reload | import.meta.hot.invalidate() |
| React plugin | @vitejs/plugin-react |
| Vue plugin | @vitejs/plugin-vue |
| Fallback | Full page reload |
| Production | HMR code tree-shaken |
Key takeaways:
- HMR is built on native ESM. Vite serves source code on demand, and the browser requests modules over ESM. When a file changes, Vite sends a WebSocket message and the browser requests the updated module, without a bundle rebuild .
- A boundary is required. A module becomes an HMR boundary by calling
import.meta.hot.accept(). The update propagates up the import chain until it finds a boundary. If no boundary exists, Vite falls back to a full page reload . disposecleans up side effects. The old module instance may have created timers, listeners, or subscriptions.import.meta.hot.dispose()registers a callback that runs before the module is replaced, allowing the old instance to clean up .datapersists across replacements. Theimport.meta.hot.dataobject is shared between the old and new instances of a module. Mutating its properties passes information forward .- Framework plugins handle HMR for components. React Fast Refresh and Vue SFC HMR are implemented by the framework plugins. Application code does not need manual
import.meta.hotcalls for component updates . - HMR is development-only. In production,
import.meta.hotisundefined, and the HMR code is removed by tree-shaking. The production build uses Rollup for optimization .
Remember: HMR is not about faster reloads. It is about replacing a module without destroying the page. The mechanism is native ESM: the browser requests the changed module, and the module’s boundary code decides how to apply it. The framework plugins make this invisible for React and Vue components. The manual API exists for the cases the plugins cannot cover. And when no boundary exists, the full reload is the correct fallback—not a failure.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!