vite-syntax-hmr-api
Quick Reference
ViteHotContext: Core Methods
| Method | Purpose | Key Constraint |
|---|---|---|
accept() |
Self-accept hot updates | Whitespace-sensitive static analysis |
accept(dep, cb) |
Accept single dependency update | Path MUST be relative |
accept(deps, cb) |
Accept multiple dependency updates | Array of relative paths |
dispose(cb) |
Cleanup before module replacement | Receives data object for state passing |
prune(cb) |
Cleanup when module removed from graph | Called when no longer imported |
invalidate(message?) |
Force propagation upward | MUST call accept() before invalidate() |
data |
Persistent object across updates | NEVER reassign — ALWAYS mutate properties |
on(event, cb) |
Listen to HMR events | 8 built-in events + custom |
off(event, cb) |
Remove event listener | Must pass same callback reference |
send(event, data?) |
Send custom event to server | Buffered if WebSocket not connected |
Built-in HMR Events
| Event | When Fired |
|---|---|
vite:beforeUpdate |
HMR update is about to be applied |
vite:afterUpdate |
HMR update has been applied |
vite:beforeFullReload |
Full page reload is imminent |
vite:beforePrune |
Modules are about to be pruned |
vite:invalidate |
A module has been invalidated via hot.invalidate() |
vite:error |
An error occurred (e.g., syntax error in updated module) |
vite:ws:disconnect |
WebSocket connection to dev server lost |
vite:ws:connect |
WebSocket connection (re-)established |
Critical Warnings
NEVER use HMR APIs without the guard pattern — production builds MUST tree-shake all HMR code:
if (import.meta.hot) {
// ALL HMR code goes here
}
NEVER add whitespace in import.meta.hot.accept( — Vite uses static analysis that is whitespace-sensitive. Writing import.meta.hot.accept ( or splitting across lines breaks HMR detection.
NEVER reassign import.meta.hot.data — the object reference is shared across updates. ALWAYS mutate properties on it:
// CORRECT: mutate properties
import.meta.hot.data.count = 42
// WRONG: reassignment breaks persistence
import.meta.hot.data = { count: 42 }
NEVER call invalidate() without calling accept() first — Vite requires a module to accept updates before it can invalidate. Without accept(), the module is not an HMR boundary and invalidate() has no effect.
NEVER use const for re-exported values in HMR boundaries — Vite's HMR does NOT swap the originally imported binding. ALWAYS use let for values that must update through re-exports.
Decision Trees
Should This Module Self-Accept?
Does the module have side effects (DOM manipulation, event listeners, timers)?
├─ YES → Does it need to clean up those side effects on update?
│ ├─ YES → Use accept() + dispose() + data persistence
│ └─ NO → Use accept() only
└─ NO → Is it a pure data/utility module?
├─ YES → Do NOT self-accept — let the importer handle updates
└─ NO → Does a parent module accept this as a dependency?
├─ YES → No action needed (parent is the HMR boundary)
└─ NO → Full reload will occur — ALWAYS add accept() to prevent this
Why Is My Module Causing Full Reload?
Does the changed module call import.meta.hot.accept()?
├─ NO → Does ANY importer accept it as a dependency?
│ ├─ NO → Full reload: no HMR boundary exists
│ └─ YES → HMR update through importer boundary
└─ YES → Does the accept callback throw an error?
├─ YES → Full reload: accept handler failed
└─ NO → Does it call invalidate() that propagates to root?
├─ YES → Full reload: invalidation reached root
└─ NO → HMR update should work — check console for errors
Patterns
Pattern 1: Self-Accepting Module with State Persistence
let state = { count: 0, items: [] }
// Restore state from previous module version
if (import.meta.hot) {
if (import.meta.hot.data.state) {
state = import.meta.hot.data.state
}
}
function render() {
document.getElementById('app')!.innerHTML = `Count: ${state.count}`
}
render()
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (newModule) {
newModule.default?.() // call updated render if needed
}
})
import.meta.hot.dispose((data) => {
data.state = state // pass state to next version
})
}
Pattern 2: Dependency-Accepting Module
import { formatDate } from './formatters.js'
import { validate } from './validators.js'
function processForm(input: string) {
if (validate(input)) {
return formatDate(new Date())
}
}
if (import.meta.hot) {
import.meta.hot.accept(
['./formatters.js', './validators.js'],
([newFormatters, newValidators]) => {
// Re-run logic with updated dependencies
// Modules are undefined if they had syntax errors
},
)
}
Pattern 3: Custom Client-Server Events
// Client side — send event to server
if (import.meta.hot) {
import.meta.hot.send('my:from-client', { msg: 'Hello server' })
import.meta.hot.on('my:from-server', (data) => {
console.log('Server says:', data.msg)
})
}
Pattern 4: Conditional Invalidation
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if (!newModule) {
// Syntax error — module is undefined
return
}
if (newModule.API_VERSION !== currentApiVersion) {
// Breaking change — propagate upward
import.meta.hot.invalidate('API version changed')
return
}
// Safe to apply update
applyUpdate(newModule)
})
}
Pattern 5: TypeScript Setup for HMR Types
Add to tsconfig.json:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
For typed custom events, declare in a .d.ts file:
import 'vite/types/customEvent.d.ts'
declare module 'vite/types/customEvent.d.ts' {
interface CustomEventMap {
'my:from-server': { msg: string }
'my:from-client': { msg: string }
}
}
Then use InferCustomEventPayload<T> for type-safe event handling:
import type { InferCustomEventPayload } from 'vite/types/customEvent.d.ts'
type ServerPayload = InferCustomEventPayload<'my:from-server'>
HMR Boundary Concept
An HMR boundary is any module that calls import.meta.hot.accept(). When a file changes, Vite walks up the import chain until it finds a boundary. If no boundary exists before reaching the entry point, a full page reload occurs.
How Updates Propagate
changed-file.ts
└─ importer-a.ts (no accept) → keeps propagating
└─ importer-b.ts (has accept) → BOUNDARY — update stops here
└─ importer-c.ts → not affected
Re-export Constraint
Vite does NOT swap the original module binding. When a boundary module re-exports values from an updated dependency, those re-exports MUST use let, not const:
// CORRECT — allows HMR to update the binding
export let config = loadConfig()
// WRONG — const binding cannot be updated by HMR
export const config = loadConfig()
When Full Reload Occurs
| Scenario | Why | Fix |
|---|---|---|
| No HMR boundary in import chain | Update propagates to root entry | Add accept() in the module or an importer |
| Accept callback throws | Vite treats failed accept as unhandled | Fix the error in the callback; use try/catch |
invalidate() propagates to root |
No parent accepts the invalidated module | Add accept boundary higher in the chain |
| HTML file changes | HTML is the entry — always reloads | Expected behavior, no fix needed |
| Config file changes | Vite restarts the dev server | Expected behavior, no fix needed |
Reference Links
- references/api-reference.md -- Complete ViteHotContext TypeScript interface and built-in events
- references/examples.md -- Working code examples for all HMR patterns
- references/anti-patterns.md -- Common HMR mistakes and why they fail
Official Sources
- https://vite.dev/guide/api-hmr -- HMR API documentation
- https://vite.dev/guide/api-plugin#handlehotupdate -- Plugin-side HMR handling