Vite’s Hot Module Replacement: Native ESM Keeps Your App State Intact
Vite’s Hot Module Replacement uses native ES modules to update only changed parts of an app, preserving state with React Fast Refresh. See a concrete example, how to use <code>import.meta.hot</code>, and common pitfalls.
18 May 2026, 07:46 UTC

Why HMR Matters in Vite
When a developer edits a source file, a full page reload would normally lose UI state and waste time. Vite solves this by serving modules as native ES modules (ESM) and updating only the changed parts. The result is a near‑instant refresh that preserves state, making the dev experience feel like a live app.
How Vite Implements HMR with ESM
Vite’s dev server performs three key actions:
- On‑Demand Transformation – When the browser requests a file, Vite transforms it (e.g., TypeScript → JavaScript) and sends it as an ESM.
- Module Graph Construction – Each module’s imports are recorded, forming a graph that Vite can walk to find dependents.
- Hot Update Delivery – When a file changes, the server invalidates that module and pushes a small update payload to the browser. Only the affected module and its dependents are re‑fetched.
In the browser, the HMR runtime receives the payload, replaces the old module, and runs any import.meta.hot.accept callbacks that the module exported. For frameworks like React, the @vitejs/plugin-react injects Fast Refresh logic that automatically preserves component state without manual code.
Worked Example: A Counter That Keeps Its Value
Below is a minimal, fully working Vite + React project that demonstrates HMR preserving state. The example uses the standard React Fast Refresh; no custom import.meta.hot code is required.
npm create vite@latest my-app -- --template react
cd my-app
npm install
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
// src/App.jsx
import { useState } from 'react'
export default function App() {
const [count, setCount] = useState(0)
return (
<div>
<h1>Counter</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
)
}
Run the dev server:
npm run dev
Open http://localhost:5173 in a browser. Increment the counter a few times. Now open src/App.jsx and change the initial state to useState(10). Save the file. You should see the UI update instantly, but the counter value remains whatever it was before the edit – React’s Fast Refresh preserved the component’s internal state. In the Network tab you’ll see only App.jsx being re‑fetched; the page never reloads.
Using import.meta.hot in Non‑Framework Modules
For plain JavaScript modules that aren’t part of a framework’s HMR system, you can manually hook into the update cycle. The following example shows a simple logger that keeps a counter across updates.
// src/logger.js
let counter = 0
export function log(msg) {
console.log(`[${++counter}] ${msg}`)
}
// HMR handling – optional but demonstrates the API
if (import.meta.hot) {
import.meta.hot.accept()
import.meta.hot.dispose(() => {
// Clean up if needed before the module is replaced
console.log('logger module is about to be replaced')
})
}
Import log in any component and call it. When you edit logger.js, Vite will fetch the new module and execute the dispose callback before replacement, then run the new code. The counter in the log output will continue incrementing, showing that state persisted across the update.
Common Pitfalls and Limits
- Circular Dependencies – If two modules import each other, Vite may fall back to a full page reload because the graph cannot be resolved cleanly. Inspect the console for warnings like "Circular dependency detected".
- Large Projects – With thousands of modules, the browser may issue many parallel requests, causing a waterfall effect. Consider pre‑bundling more dependencies with
optimizeDepsor splitting the app into separate entry points. - Production Builds – Production bundles use Rollup, so
import.meta.hotis undefined. Guard any HMR‑specific code withif (import.meta.hot)to avoid runtime errors. - Missing Guard – Using
import.meta.hotin a module that isn’t served by Vite’s dev server (e.g., a built utility) will throw. Always check the guard.
Verifying HMR Works
- Start the dev server with
npm run dev. The console should show:Vite vX.X.X dev server running at http://localhost:5173
- Open the browser and edit a component. The Network tab should request only the changed module (e.g.,
App.jsx). No full page reload should occur. - Check the console for HMR log messages such as "[HMR] Updated module: App.jsx". If a full reload happens, the console will usually report a circular dependency or an error that forced a fallback.
Conclusion
Vite’s HMR leverages native ESM to avoid bundling during development, keeps a lightweight module graph, and updates only the necessary parts of the app. For React projects, the built‑in Fast Refresh automatically preserves state, so developers rarely need to touch import.meta.hot directly. When you do need custom HMR logic, guard against production builds and be mindful of circular dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.