6 min read
Dev Server & HMR
Why hot updates fall back to a full reload, where state needs to live to survive an edit, and cleaning up before a module gets swapped.
Principles
HMR boundaries are modules that accept their own updates
A module survives a hot update only if something in its chain calls `import.meta.hot.accept()`. Without an accepting boundary, Vite falls back to a full reload, which feels like 'HMR is broken' when it's actually just absent.
State lives above the component that re-executes on HMR
A component module reloading in place loses local state unless a parent that didn't reload owns it. Volatile state you want to survive edits belongs in a store outside the hot-swapped module.
A stale closure over old module state is the classic HMR bug
An event listener registered with a reference to the pre-update module can keep running after the update, producing behaviour that looks like the edit 'didn't take'.
The dev server's module graph is not the production bundle graph
Code that works in dev because Vite serves ES modules natively can behave differently once Rollup bundles it for production. Test builds, not just dev server output, before shipping.
Slow HMR usually means a boundary is too high, not that Vite is slow
An update propagating all the way to the app root because nothing accepted it lower down forces a full page reload every time. Push the accept boundary down to where the change actually lives.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Missing HMR acceptance causing full reloads
Avoid
// store.ts — plain module state, no HMR boundary
export let count = 0;
export function increment() { count++; }
// Editing this file triggers a full page reload every time,
// even though the change is trivial.No module in the chain accepts the update, so Vite has no choice but to reload the whole page.
Prefer
export let count = 0;
export function increment() { count++; }
if (import.meta.hot) {
import.meta.hot.accept(); // this module now hot-swaps in place
}The module declares it can handle its own replacement — edits apply without losing the rest of the app's state.
State trapped in a component that gets hot-swapped
Avoid
function Counter() {
const [count, setCount] = useState(0); // resets to 0 on every HMR update
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}Every edit to this file — even an unrelated style tweak — resets the count back to zero.
Prefer
// store.ts — outside the component that gets hot-swapped
export const useCounterStore = create<{ count: number; increment: () => void }>(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
);
function Counter() {
const { count, increment } = useCounterStore(); // survives the reload
return <button onClick={increment}>{count}</button>;
}The state lives in a module that isn't part of the hot-swapped component, so it survives edits to the component's own code.
Stale closure after an update
Avoid
// listener.ts
let handlers: Array<() => void> = [];
export function onEvent(fn: () => void) { handlers.push(fn); }
export function fire() { handlers.forEach((fn) => fn()); }
// A previous module instance's handlers array keeps receiving
// events after an HMR update creates a *new* handlers array.The old module instance's closures are still subscribed somewhere, so the update appears to have no effect.
Prefer
let handlers: Array<() => void> = [];
export function onEvent(fn: () => void) { handlers.push(fn); }
export function fire() { handlers.forEach((fn) => fn()); }
if (import.meta.hot) {
import.meta.hot.dispose(() => {
handlers = []; // explicitly clear state the old instance owned
});
}`dispose` runs right before the update swaps the module, giving it a chance to clean up references the new instance shouldn't inherit.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Modules with meaningful local state either accept their own HMR updates or intentionally trigger a full reload.
- State meant to survive edits lives in a store outside the component/module that gets hot-swapped.
- `import.meta.hot.dispose()` cleans up subscriptions and listeners before a module is replaced.
- Slow or constantly-full-reloading HMR is diagnosed by checking which boundary is missing `accept()`, not dismissed as inherent slowness.
- Dev-server-only behaviour differences from production are verified against an actual `vite build` output, not assumed identical.
- HMR-related code (`import.meta.hot` guards) is inert in production builds, not left running.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/vite-dev-server-hmr/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: vite-dev-server-hmr
description: >-
Vite dev server and HMR: accepting hot updates at the right module
boundary, keeping state alive across component reloads, cleaning up with
import.meta.hot.dispose, and diagnosing full-reload fallbacks. Use when
HMR feels broken or state resets unexpectedly.
argument-hint: "[module or component]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Dev Server & HMR
Apply when a dev-server edit causes an unexpected full reload or lost state. Trace which module should have accepted the update, and check whether state that should survive editing actually lives outside that module.
## Rules
1. **HMR boundaries are modules that accept their own updates** — A module survives a hot update only if something in its chain calls `import.meta.hot.accept()`. Without an accepting boundary, Vite falls back to a full reload, which feels like 'HMR is broken' when it's actually just absent.
2. **State lives above the component that re-executes on HMR** — A component module reloading in place loses local state unless a parent that didn't reload owns it. Volatile state you want to survive edits belongs in a store outside the hot-swapped module.
3. **A stale closure over old module state is the classic HMR bug** — An event listener registered with a reference to the pre-update module can keep running after the update, producing behaviour that looks like the edit 'didn't take'.
4. **The dev server's module graph is not the production bundle graph** — Code that works in dev because Vite serves ES modules natively can behave differently once Rollup bundles it for production. Test builds, not just dev server output, before shipping.
5. **Slow HMR usually means a boundary is too high, not that Vite is slow** — An update propagating all the way to the app root because nothing accepted it lower down forces a full page reload every time. Push the accept boundary down to where the change actually lives.
## Patterns
### Missing HMR acceptance causing full reloads
**Avoid** — No module in the chain accepts the update, so Vite has no choice but to reload the whole page.
```ts
// store.ts — plain module state, no HMR boundary
export let count = 0;
export function increment() { count++; }
// Editing this file triggers a full page reload every time,
// even though the change is trivial.
```
**Prefer** — The module declares it can handle its own replacement — edits apply without losing the rest of the app's state.
```ts
export let count = 0;
export function increment() { count++; }
if (import.meta.hot) {
import.meta.hot.accept(); // this module now hot-swaps in place
}
```
### State trapped in a component that gets hot-swapped
**Avoid** — Every edit to this file — even an unrelated style tweak — resets the count back to zero.
```tsx
function Counter() {
const [count, setCount] = useState(0); // resets to 0 on every HMR update
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
```
**Prefer** — The state lives in a module that isn't part of the hot-swapped component, so it survives edits to the component's own code.
```tsx
// store.ts — outside the component that gets hot-swapped
export const useCounterStore = create<{ count: number; increment: () => void }>(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
);
function Counter() {
const { count, increment } = useCounterStore(); // survives the reload
return <button onClick={increment}>{count}</button>;
}
```
### Stale closure after an update
**Avoid** — The old module instance's closures are still subscribed somewhere, so the update appears to have no effect.
```ts
// listener.ts
let handlers: Array<() => void> = [];
export function onEvent(fn: () => void) { handlers.push(fn); }
export function fire() { handlers.forEach((fn) => fn()); }
// A previous module instance's handlers array keeps receiving
// events after an HMR update creates a *new* handlers array.
```
**Prefer** — `dispose` runs right before the update swaps the module, giving it a chance to clean up references the new instance shouldn't inherit.
```ts
let handlers: Array<() => void> = [];
export function onEvent(fn: () => void) { handlers.push(fn); }
export function fire() { handlers.forEach((fn) => fn()); }
if (import.meta.hot) {
import.meta.hot.dispose(() => {
handlers = []; // explicitly clear state the old instance owned
});
}
```
## Review checklist
- [ ] Modules with meaningful local state either accept their own HMR updates or intentionally trigger a full reload.
- [ ] State meant to survive edits lives in a store outside the component/module that gets hot-swapped.
- [ ] `import.meta.hot.dispose()` cleans up subscriptions and listeners before a module is replaced.
- [ ] Slow or constantly-full-reloading HMR is diagnosed by checking which boundary is missing `accept()`, not dismissed as inherent slowness.
- [ ] Dev-server-only behaviour differences from production are verified against an actual `vite build` output, not assumed identical.
- [ ] HMR-related code (`import.meta.hot` guards) is inert in production builds, not left running.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim