| | |

Vite 06 ⚡ Hot Module Replacement — How HMR works without full reloads

⏱️ Estimated read: 11 min | |

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

MethodPurpose
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.dataPersistent data across replacements
import.meta.hot.invalidate()Force a full reload

HMR boundaries

BoundaryDefined by
Self-accepting moduleimport.meta.hot.accept()
Dependency-accepting moduleimport.meta.hot.accept('./dep.js', cb)
Framework componentPlugin (React Fast Refresh, Vue SFC)
No boundaryFull page reload

Framework plugins

FrameworkPluginHMR mechanism
React@vitejs/plugin-reactReact Fast Refresh
React (SWC)@vitejs/plugin-react-swcReact Fast Refresh
Vue@vitejs/plugin-vueSFC virtual modules

Production behavior

DevelopmentProduction
import.meta.hot existsimport.meta.hot is undefined
HMR code runsHMR code is tree-shaken
WebSocket updatesNo 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

PitfallWhy It HappensFix
HMR not workingNo module accepts the updateAdd import.meta.hot.accept() or use a framework plugin
Full reload on every changeThe changed file has no boundaryCheck the import chain; add a boundary at the right level
State resetsThe boundary is above the stateful componentMove the boundary down
import.meta.hot is undefinedProduction buildExpected; guard the code
Side effects duplicateNo dispose cleanupAdd 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

ItemValue
FoundationNative ESM, on-demand module serving
Client APIimport.meta.hot
Boundaryimport.meta.hot.accept()
Cleanupimport.meta.hot.dispose()
Persistent dataimport.meta.hot.data
Force reloadimport.meta.hot.invalidate()
React plugin@vitejs/plugin-react
Vue plugin@vitejs/plugin-vue
FallbackFull page reload
ProductionHMR 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 .
  • dispose cleans 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 .
  • data persists across replacements. The import.meta.hot.data object 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.hot calls for component updates .
  • HMR is development-only. In production, import.meta.hot is undefined, 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.

Was this helpful?


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!