Home

How Vite's HMR works under the hood

Why some saves reload the page and others don't.

·24 min read
01Sep 25How Vite's HMR works under the hoodWhy some saves reload the page and others don't.
02Sep 29What vite build does to your filesHow Vite turns imports into production files.
🔖 TL;DR
  • Vite loads changed modules through timestamped URLs so the browser executes new code. Existing imports still refer to old versions, so the app needs code that applies the update.
  • With accept(), a module declares it can update without re-running its importers. Without acceptance, Vite checks those importers; reaching a module with neither acceptance nor importers requires a page reload.
  • For CSS, Vite generates JavaScript that rewrites existing style elements. React's Fast Refresh makes mounted components use the edited component functions.
  • Fast Refresh can apply new component code while preserving state. Changes to hooks can make Fast Refresh remount the component with fresh state while the page stays open.

Open Vite's React TypeScript starter with npm run dev and click its button three times. The button reads Count is 3. src/App.tsx stores the count with useState. Now change the button's padding in src/App.css:

css diff
 .counter {
   font-size: 16px;
-  padding: 5px 10px;
+  padding: 20px 40px;
   border-radius: 5px;

The button grows when you save, but the count stays at 3. The browser has applied the saved CSS while the app keeps running. This is hot module replacement, or HMR.


An edit to src/main.tsx, the file that starts the app, reloads the page and returns the count to 0. Why can a CSS edit update the running page, while an edit to main.tsx starts the app over?

Why importing App.css runs JavaScript

In development, Vite serves the application's source files as separate modules. The browser follows their imports by requesting modules from the dev server. App.tsx includes this stylesheet import:

tsx
import './App.css'

A plain import asks the browser to load a JavaScript module. The .css suffix does not turn it into a stylesheet request. With <link rel="stylesheet">, the browser parses the response as CSS and applies its rules to the document. For the import shown here, the browser checks the response's Content-Type for a JavaScript type; text/css makes the import fail.


Vite makes this import work by returning JavaScript. Its built-in CSS plugin puts the stylesheet text in a string and generates code that inserts it into the page:

GET/src/App.css
200OKtext/javascript
...
const __vite__id = "/…/src/App.css"
const __vite__css = "...padding: 5px 10px;..."
__vite__updateStyle(__vite__id, __vite__css)
import.meta.hot.accept()
...

Vite adds a browser script called the Vite client to the page. Its __vite__updateStyle function receives the __vite__css text and the stylesheet's path as an ID. The first call creates a <style> element in <head> and keeps it in a map under that ID:

html
<style
  type="text/css"
  data-vite-dev-id="/…/src/App.css"
>.counter {
  font-size: 16px;
  padding: 5px 10px;
  ...
}</style>

A later call with the same ID finds this element and replaces its text. That gives a new App.css module a way to change the existing page: call __vite__updateStyle with the same path and the new CSS. React's component tree does not need to change.


Vite loads an edited module under a new URL. Existing imports remain linked to the original URL, so they still refer to the old module. If that module exports a function, its callers can keep calling the old function. This stylesheet works differently: App.tsx takes no exported value from import './App.css', and executing the generated module directly rewrites the style element. The component does not need to run again to display the new padding.


When a file changes, Vite must decide which modules need to run again. Through Vite's import.meta.hot.accept() API, the generated CSS module declares that it can update without re-running App.tsx or other importers. Vite calls a module with this declaration self-accepting. The CSS plugin supplies both the declaration and the code that changes the existing style element.


Vite's development server watches the project for file changes and decides what the browser should load again. How does it know which modules declare acceptance, and which other modules import them?

How the server records imports and acceptance

Vite creates an empty module graph in memory when the development server starts. During the first page load, Vite loads and transforms the modules used by the page. It records their imports and acceptance as it does this work.


While transforming App.tsx, Vite reads its stylesheet import:

tsx
import './App.css'

Vite creates records for the two modules and connects them in both directions. The App.tsx record says it imports App.css; the App.css record says it is imported by App.tsx.


When Vite's CSS plugin transforms App.css into JavaScript, it also marks the stylesheet's graph record as self-accepting. After the initial page load, the relevant parts of the stylesheet's record look like this, with its importer shown by URL:

json
{
  "url": "/src/App.css",
  "importers": ["/src/App.tsx"],
  "isSelfAccepting": true
}

importers links the stylesheet back to the modules that use it. isSelfAccepting: true records the declaration that the stylesheet can update without re-running those modules. When the server detects a save, it uses this record to decide whether to update the stylesheet alone or check its importers.


When analyzing files such as App.tsx, Vite looks for accept() calls in the transformed code, including calls generated by plugins. The server records these declarations before the browser executes the modules.

How a save updates the stylesheet

Vite's file watcher reports that App.css changed. The server looks up the stylesheet's graph record, where isSelfAccepting is true, and asks the browser to import the new App.css module. If the stylesheet did not accept, Vite would check its importers. An importer that declares self-acceptance would then be the module the browser loads again.


The Vite client opens a WebSocket connection when the page loads. After the save, the server sends this update through that connection:

GET/?token=JlD6YGK62gN8
101Switching ProtocolsLength
{"type":"connected"}20
{"type":"update","updates":[{"type":"js-update","timestamp":1790261698868,"path":"/src/App.css","acceptedPath":"/src/App.css","explicitImportRequired":false,"isWithinCircularImport":false}]}190

The message contains no CSS. It tells the Vite client to load /src/App.css again. The client adds the supplied timestamp to the URL and imports it:

js
import('/src/App.css?t=1790261698868')

The update message calls this a js-update because the browser imports JavaScript, even though the edited file contains CSS. The resulting request returns the generated module with the saved padding:

GET/src/App.css?t=1790261698868
200OKtext/javascript
...
const __vite__id = "/…/src/App.css"
const __vite__css = "...padding: 20px 40px;..."
__vite__updateStyle(__vite__id, __vite__css)
import.meta.hot.accept()
...

The timestamp matters because the browser already loaded /src/App.css when the app started. Importing that same URL again would reuse the loaded module without executing its code again. A different URL causes another module to load. You can see the difference in the DevTools console:

js
await import('/src/App.css')
// No request; this module has already been loaded.

await import('/src/App.css?x=1')
// GET /src/App.css?x=1

The browser records loaded modules in a module map, using the URL to identify each JavaScript module. Vite uses a new timestamp for each update so the browser executes the saved code.

The timestamped URL makes the browser execute a new version of the module; the old one stays loaded. The new CSS module calls __vite__updateStyle with the same stylesheet ID. The client finds the existing <style> element and replaces its text with the new padding. Nothing remounts the React component, so the count stays at 3.

The generated CSS module declares acceptance and supplies code to rewrite the existing style element. main.tsx, the file that starts the app, has no accept declaration.

Why main.tsx reloads

main.tsx contains the code that starts React. Its createRoot call creates a React root for the page's <div id="root">, and render mounts App inside it:

tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
)

Once App is mounted, React keeps its state and updates the DOM when that state changes. main.tsx does not need to run again for a click to change the count.


The module-graph record for main.tsx has no acceptance declaration and no importers. A simplified view of that record is:

json
{
  "url": "/src/main.tsx",
  "importers": [],
  "isSelfAccepting": false
}

When a changed module does not accept, the server follows its recorded importers. But main.tsx has no importer to try. The starter's imports show why:

index.html
main.tsx
index.css
App.tsx
App.css

In this tree, each file below main.tsx sits beneath the module that imports it. The connection from index.html to main.tsx is different: the HTML file loads the entry through a <script> tag. Vite's module graph records imports between modules, so index.html does not appear in main.tsx's importers.


With the count at 3, add a title assignment after the imports and save:

tsx diff
 import App from './App.tsx'
+document.title = 'Counter'

Because main.tsx neither accepts updates nor has an importer, Vite tells the browser to reload the page. The WebSocket message is:

GET/?token=JlD6YGK62gN8
101Switching ProtocolsLength
{"type":"full-reload","triggeredBy":"/…/src/main.tsx","path":"*"}67

The browser starts the page again. The tab title becomes Counter, and the new App starts with Count is 0.


An edit to the App component can leave the existing React root and mounted component in place. The starter enables @vitejs/plugin-react, a Vite plugin that adds React update code to the JavaScript served for App.tsx.

How App handles an update

App.tsx defines the component that renders the counter and heading. Its source includes:

tsx
function App() {
  ...
  return (
    <>
      ...
      <h1>Get started</h1>
      ...
    </>
  )
}
export default App

Importing an edited App.tsx defines a new App function. React has already mounted a component using the original function, so loading the new module alone would leave that component using the old code. The React plugin adds code for Fast Refresh, which runs in the browser and works with React to update mounted components during development. Part of the JavaScript served for App.tsx is:

GET/src/App.tsx
200OKtext/javascript
...
_c = App;
$RefreshReg$(_c, "App");
...

The generated variable _c holds the App function. $RefreshReg$ registers that function with Fast Refresh under an ID made from the file path and the component name: /…/src/App.tsx App. When an edit creates another App function, the same registration code gives it the same ID. Fast Refresh can then associate the two functions as versions of the same component.


The React plugin also adds an import.meta.hot.accept() call to the JavaScript served for App.tsx, passing it a function to run after Vite loads an update.


With the counter at 3, change the heading and save:

tsx diff
-          <h1>Get started</h1>
+          <h1>Get going</h1>

The server sends a js-update for App.tsx, and the browser imports its timestamped URL. The new module registers the new App function. The Vite client then calls the function passed to accept() by the previous App.tsx. For this edit, that function asks Fast Refresh to make React render with the new App. main.tsx does not run again, and its import still refers to the original App. How does React use the new function for the component already on screen?

How Fast Refresh finds the component

Fast Refresh keeps a record for each registration ID, called a family. Both versions of App belong to the family identified by /…/src/App.tsx App. During the refresh, Fast Refresh updates this record to point to the new function and lets React find the record from either version:

Function React hasFamily it belongs toFunction to use after the refresh
Original App/…/src/App.tsx AppNew App
New App/…/src/App.tsx AppNew App

React still associates the mounted component with the original function. React uses that original function to find its family, then renders with the new function stored in that family. The import in main.tsx can keep pointing to the original App: React now has a way to find its replacement.


The count belongs to the mounted component's hook state, not to the App function object. Vite's compiler generates a string from each version's hook calls, called a hook signature. Fast Refresh compares these signatures to decide whether React can reuse that state. The heading edit leaves the signature unchanged, so React renders the existing component with the new function. useState returns the stored 3, and the new heading appears beside the unchanged count.


The three panels compare which module the server selects after the padding, heading and entry-file edits, and what happens on the page. The App.tsx panel shows the heading edit.

Save App.css
index.html
main.tsx
index.css
App.tsx
App.css
The server finds App.css recorded as self-accepting and selects it for the update.
Count is 3, kept
Save main.tsx
index.html
main.tsx
index.css
App.tsx
App.css
main.tsx does not accept, and nothing imports it. The walk runs out of modules.
Full reload, Count is 0
Save App.tsx
index.html
main.tsx
index.css
App.tsx
App.css
The server recorded App.tsx as self-accepting when it processed the plugin-generated accept(). It selects App.tsx for the update.
Count is 3, kept

The CSS and component updates both stop at the edited module, but their generated code applies the changes differently. CSS updates the style element by its ID. Fast Refresh finds the mounted component through its function family. The entry file has neither an accept call nor an accepting importer, so its edit reloads the page.


The CSS and React integrations declare acceptance and update an existing style element or mounted component. Would adding only the declaration to main.tsx let it keep the existing root?

What a bare accept call changes

Append this block to main.tsx, leaving its document.title = 'Counter' assignment in place:

tsx
if (import.meta.hot) {
  import.meta.hot.accept()
}

The save that adds this call still reloads: the server makes that decision using the existing record, which has no acceptance declaration. For the reloaded page, Vite transforms the edited main.tsx and records its new accept() call. On the reloaded page, running accept() sets up the Vite client to import future versions of main.tsx when the server announces an update.


Click the button until it reads Count is 3, then change the title assignment and save:

tsx diff
-document.title = 'Counter'
+document.title = 'Counter again'

This time the server sends a js-update for main.tsx, and the browser imports the new module without reloading the page. The entry calls createRoot on the page's <div id="root"> again, and mounts App with fresh state. The count becomes 0, and React reports:

You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. Instead, call root.render() on the existing root instead if you want to update it.

accept() allowed the new main.tsx to execute, but it did not make createRoot reuse the existing root. The new entry repeated initialization instead of updating the root that was already on the page.


Suppose App.tsx also imports a plain helper function. What happens when that function changes? The helper has no component for Fast Refresh to update. Applying its change requires the mounted component to render with the new helper function.

When an imported helper changes

Remove the title assignment and manual HMR block from main.tsx, save, and reload. Then move the button's text into a plain helper, src/label.ts:

ts
export function label(n: number) {
  return `Count is ${n}`
}

Use it in App.tsx:

tsx diff
 import { useState } from 'react'
+import { label } from './label'
 ...
-          Count is {count}
+          {label(count)}

Vite records that App.tsx imports label.ts.


Why doesn't Vite make this helper self-accepting too? Executing a new label.ts creates a new function, but the existing App.tsx still imports the old one. If Vite stopped the update at the helper, the component would keep calling the old function and displaying the old text.


Fast Refresh solves that problem for components because React controls when they render and which function to call. Fast Refresh has no equivalent operation for replacing an ordinary function wherever application code uses it. A helper's result might already have been used to build part of the page; importing a new function does not recompute that result.


The React plugin supplies no accept callback for this plain helper, so Vite checks its importers for acceptance. It finds the accept declaration in App.tsx and asks the browser to import that module again. In the response for App.tsx, Vite rewrites the helper's import to its timestamped URL. The new App.tsx therefore imports the edited label, and Fast Refresh makes React render with the new App that calls it.


With the count at 3, change Count is to Clicks: in label.ts and save. The server looks up label.ts in the graph. Its record has no self-acceptance declaration, so Vite follows its importers to App.tsx. That module declares self-acceptance, and the server sends an update for App.tsx:

GET/?token=JlD6YGK62gN8
101Switching ProtocolsLength
{"type":"update","updates":[{"type":"js-update","timestamp":1790264226993,"path":"/src/App.tsx","acceptedPath":"/src/App.tsx","explicitImportRequired":false,"isWithinCircularImport":false}]}190

The message's acceptedPath is /src/App.tsx, even though the edited file is label.ts. The browser requests /src/App.tsx?t=1790264226993. In the returned code, Vite has also rewritten the import of the changed helper:

js
import { label } from "/src/label.ts?t=1790264226993";

The server records the edit's timestamp for both modules while preparing the update. When serving App.tsx, Vite uses the timestamp recorded for label.ts to rewrite this import. The import in App.tsx on disk has not changed; the response points to the helper's new URL.


That new dependency URL causes a second request, for /src/label.ts?t=1790264226993. The new App function now calls the new label function. Fast Refresh connects the new App to the mounted component, and the button becomes Clicks: 3. If the returned App.tsx still used /src/label.ts without the timestamp, the browser would reuse the old helper from its module map. (Timestamped dependency imports)


The helper needed App.tsx to run again because it had no code for updating its callers. What changes if the button text comes from a component instead? Replace the helper with src/Label.tsx:

tsx
export function Label({ n }: { n: number }) {
  return <>Count is {n}</>
}

Then render it from App.tsx:

tsx diff
-import { label } from './label'
+import { Label } from './Label'
 ...
-          {label(count)}
+          <Label n={count} />

When compiling Label.tsx, Vite generates a Fast Refresh registration for this component. The React plugin adds an accept callback to Label.tsx. With the count at 3, change Count is to Clicks: in that component and save. The server sends an update for /src/Label.tsx, and the browser imports its timestamped URL. App.tsx still holds the old Label function, but React can look up that function's family and use its new implementation. Fast Refresh therefore updates the mounted Label without fetching or executing App.tsx again. The button again reads Clicks: 3.


Updating the plain helper required Vite to import App.tsx again, so the new App would call the new label. Updating Label required only the component module: its generated Fast Refresh code let React use the new function.

Why a component's state resets

The heading and helper edits kept App's hook signature unchanged. Which edits change that signature? React associates stored hook state with hook calls in their execution order. In the starter's App, the first useState call returns the counter's stored value:

Hook callStored state
First call: useState(0)3, returned as count

If a new useState call appeared before the counter's call, reusing the stored values in the same order would give the new hook the counter's 3.


The hook signature lets Fast Refresh detect this change before React renders the update. Adding a hook changes the signature generated by Vite's compiler. Fast Refresh requests a remount, and React creates fresh hook state for the edited component.


Restore App.tsx to the template and remove the helper files. With the count at 3, add a hook before the counter's hook:

tsx diff
+  const [extra] = useState('x')
   const [count, setCount] = useState(0)

Vite sends a js-update for App.tsx, and the page stays open. The button returns to Count is 0 because Fast Refresh remounts App. A component losing its state does not by itself mean the browser reloaded the page.


The compiler includes more than the number and order of hooks in the signature. It also includes variable names such as count and setCount, and the initializer passed to useState. Restore the original component and click the counter to 3. Changing useState(0) to useState(5) then makes the button show Count is 5. The remount allows the new initializer to run; a normal re-render would keep the stored 3. Renaming setCount throughout the original component also changes its signature and resets the count to 0.


These hook edits change the component itself. What happens if App.tsx also exports a number that other code uses?

Why changing exports can reload the page

Fast Refresh can update the App component without re-running modules that import App.tsx. Those modules may also use numbers or other values from App.tsx. Replacing the component function does not update those imported values. To update the component without re-running those modules, the React plugin requires the same export names and unchanged values for any exports that are not components.


Restore the original App.tsx, click the counter to 3, and append:

ts
export const VERSION = 1

Vite initially sends a js-update for App.tsx. After loading the new module, the Vite client runs the function registered by the previous version's accept() call. This callback compares the old and new exports:

Exports compared by the callbackContents after adding VERSION
Previous module's exportsdefault: original App function
New module's exportsdefault: new App function; VERSION: 1

The exported names no longer match. The React plugin treats an added or removed export as a change that may affect importing modules. Its generated callback asks Vite's server to check whether those importers accept updates.


The generated code stores its module's exports in currentExports when the module runs. The callback retains that reference, and Vite supplies the updated exports as nextExports:

js
import.meta.hot.accept((nextExports) => {
  if (!nextExports) return;
  const invalidateMessage = RefreshRuntime
    .validateRefreshBoundaryAndEnqueueUpdate(
      "/…/src/App.tsx",
      currentExports,
      nextExports
    );
  if (invalidateMessage) {
    import.meta.hot.invalidate(invalidateMessage);
  }
});

With the added VERSION export, the check returns a reason in invalidateMessage. The callback passes that reason to invalidate() instead of requesting a refresh. The browser can reject an update after the server has selected the module.


invalidate() reports the rejected update to the server over the WebSocket. The server continues through the modules that import App.tsx, reaching main.tsx. That module does not accept and has no further importer, so the server sends full-reload. The full exchange is:

GET/?token=JlD6YGK62gN8
101Switching ProtocolsLength
{"type":"update","updates":[{"type":"js-update","timestamp":1790264829728,"path":"/src/App.tsx","acceptedPath":"/src/App.tsx","explicitImportRequired":false,"isWithinCircularImport":false}]}190
{"type":"custom","event":"vite:invalidate","data":{"path":"/src/App.tsx","message":"Could not Fast Refresh (new export)","firstInvalidatedBy":"/src/App.tsx"}}158
{"type":"full-reload","triggeredBy":"/…/src/App.tsx","path":"*"}66

The first message asks the page to import App.tsx. The outgoing vite:invalidate message reports that the new export prevents a refresh. The final message tells the browser to reload, which starts the count again at 0.


Fast Refresh also checks the exported values when the export names match. For a regular function like App, it uses the capitalized name to recognize a likely React component and allows the function to change. React can use the component's family to find the new function even while its importers hold the old one. The numeric VERSION export has no such lookup: its importers would keep using the old number. The runtime therefore requires the old and new values of VERSION to be equal.


After the page reloads with VERSION = 1, a heading edit that leaves it at 1 can refresh successfully. Changing it to 2 fails the value check. A value created anew each time the module runs can fail the check even without an edit to that value's source. For example, export const settings = { theme: 'light' } creates a different object each time, so the old and new exports are not equal. The runtime rejects that update rather than refreshing the component while its importers keep the old object.

Where to look when state disappears

A hook change and an export change can both return the count to 0, but for different reasons. To distinguish them, enable Preserve log in Chrome's Network panel before editing, then inspect the WebSocket messages. A full-reload message tells the browser to start the page again. If it follows an outgoing vite:invalidate message, that earlier message identifies the module that rejected the update and gives the reason, such as an added export.


A js-update asks the client to import updated code. Adding a hook before the counter's useState(0) can produce that message and still return the count to 0. Fast Refresh remounts App, so React starts its hook state from the initial values again while the page stays open.