Vite Dev Server and HMR
You change some text inside a React component, hit save, and almost immediately the browser updates.
Nice.
But who actually did that? Was it React? Vite? Browser? And why does your component state sometimes stay there after editing the file, while another edit suddenly reloads everything and your state goes back to zero?
There are actually a few programs involved in this little save-and-see-the-result loop. Your editor writes the changed file to disk, Vite notices that file changed and sends an update to the browser, and then React Fast Refresh gets involved if that update contains React component code.
And yeah, sometimes browser can update the page without reloading the whole document. But don't assume every update you see on screen came from the same process. Clicking a button and calling setCount() can cause React to render, and saving Counter.jsx can also cause React to render, but those two started for completely different reasons.
We'll separate those as we go.
The dev server is just for development
Start your project:
npm run devAnd maybe keep that terminal open somewhere. You'll be looking at it quite often once something stops working.
When Vite starts, it runs an HTTP server on your machine. That server gives the browser index.html, processes source modules when browser requests them, resolves imports from packages, and keeps a connection open so it can tell the browser when one of your files changed.
During development, browser can request source modules separately.
index.html
/src/main.jsx
/src/App.jsx
/src/ChapterCard.jsx
/src/index.cssSo Vite doesn't first need to build one final production bundle before it can show you the page. Browser requests a module, Vite processes it and sends it. It also pre-bundles some dependencies during development so package loading behaves better.
Production works differently. When you run:
vite buildVite produces the files you're actually going to deploy.
The server you get from npm run dev is for local development. Don't put that server directly on the internet and call it your production setup.
Running Vite with --host 0.0.0.0 only makes your dev server reachable from other devices on the network. It doesn't somehow convert your development server into a production server.
Let's edit one component
Use something with state because then we can actually see what gets preserved.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>;
}Run the app and click that button until it says 3.
Now don't touch the state code. Just change the text around it:
return <button onClick={() => setCount((n) => n + 1)}>Count: {count}</button>;Save.
Normally the browser will now show Count: 3.
Notice what happened there. The source code changed, React started using the new component code, but your count didn't reset back to zero.
There are two different things helping with that result. Vite updated the module without throwing away the whole current browser document, and React Fast Refresh was able to keep the existing compatible component and its local state.
Vite itself doesn't know what your useState() means. It isn't sitting there keeping track of Hook values. The React plugin connects Vite's update system with Fast Refresh, and React then decides whether your edited component can continue using its existing state.
HMR and Fast Refresh are not the same thing
These two terms are often thrown around together, so it's easy to start treating them as one feature.
HMR means Hot Module Replacement. This comes from Vite and works at the module level.
React doesn't even need to be involved.
Change some CSS:
.counter {
color: #61dafb;
}Save it, and Vite can replace that CSS while the current page stays loaded.
That's HMR.
React Fast Refresh is the React-specific side. When a changed module contains React components, the React plugin registers those component exports, then Fast Refresh helps React replace compatible component code while trying to preserve state when it can.
The flow is roughly:
you save a file
-> Vite notices the change
-> Vite works out which modules are affected
-> browser receives the HMR update
-> React plugin handles React component exports
-> Fast Refresh updates compatible component code
-> React renders affected components againNow compare that with clicking the counter button.
When you click it, no source file changed. Vite didn't send updated JavaScript. Your state setter ran, React scheduled an update, and React rendered because application state changed.
Both can result in the component function running again. That's why they can look almost identical if you're only watching the screen.
But one started because your app changed state. The other started because you edited source code.
Why does state sometimes survive?
Fast Refresh tries to preserve local React state when the edited code can still be treated as the same component.
Simple changes usually work fine. You can edit some JSX, change a label, or change ordinary rendering logic:
const label = count === 1 ? "lesson" : "lessons";Your count can remain exactly where it was.
But React also has to keep the Hook setup valid.
For example, this code is already broken:
function Counter({ detailed }) {
if (detailed) {
const [label, setLabel] = useState("Lessons");
}
const [count, setCount] = useState(0);
// ...
}That conditional useState() violates the Rules of Hooks. Fast Refresh doesn't make that okay.
If you add or remove Hooks while editing, React may reset state so it can start again with the new Hook setup. And that's fine. State preservation during source editing is there to make development nicer, not something your application should depend on.
Your real users aren't sitting there editing Counter.jsx while using the deployed app.
So if some data actually needs to survive reloads, save it somewhere meant for that: maybe server data, the URL, IndexedDB, localStorage, or whatever fits the application.
Don't rely on Fast Refresh for user data.
What you export from a module can affect refresh
Fast Refresh generally behaves best when a component file mostly exports React components.
Say you have this:
export const chapterLimit = 5;
export default function ChapterList() {
// ...
}At first maybe nothing seems wrong.
But now some unrelated non-React module also imports chapterLimit. You edit this file, and Vite has more consumers to deal with than just a React component tree. Depending on how that module is imported, the update may spread farther through the module graph, or Vite may decide it can't safely accept that update and reload the page.
If that exported value starts getting used outside the component code, moving it out can make things cleaner.
// chapter-config.js
export const chapterLimit = 5;Then:
// ChapterList.jsx
import { chapterLimit } from "./chapter-config.js";Does this mean every constant deserves its own file?
Please don't.
The point is just that once some exported value is being consumed by unrelated code, maybe it no longer belongs inside the component module.
And if one particular component file keeps causing full page reloads whenever you touch it, checking what that file exports and who imports those exports is a good place to start.
Full reload is a very different event
Now manually reload your browser.
Everything feels different because it is.
The browser throws away the current document and starts loading the application again.
current document is discarded
browser requests index.html again
JavaScript modules load again
createRoot runs again
components start againAny state that only existed in JavaScript memory is gone.
Our counter goes back to zero.
A source edit can also cause this if Vite can't handle it through the current HMR chain, or if some module error leaves it unable to continue with a hot update.
This is why I still manually reload the app after doing a bunch of edits.
Fast Refresh can keep your current development state alive for quite a long time, which is useful while working. But I also want to know whether the app still works when somebody opens it from zero.
Maybe you edited something ten times while count was already 3, some form already had values, and a component already had data sitting in memory. Reloading removes all of that and shows you what a new page load actually does.
If the app only works after fifteen Fast Refresh updates and breaks after one normal reload, we definitely have a problem.
Syntax errors are also part of development
Let's break something on purpose.
return <button>{count</button>;Save that.
Vite tries to process the changed module, but the JSX isn't valid, so it can't produce valid updated code for the browser. You'll normally see the error in the terminal, and Vite can also show an error overlay in the browser.
The previous valid version of your app may still be sitting behind that overlay.
Fix it:
return <button>{count}</button>;Save again, Vite can process the module this time, and the next update goes through.
Sometimes state survives after fixing an error. Sometimes it doesn't.
I wouldn't spend too much energy trying to preserve state through broken source files. If the code was invalid for a moment and React had to reset something after you repaired it, okay.
Much more useful question is: does the fixed code work properly from a clean page load?
Also pay attention to which program is reporting the error.
If Vite couldn't process the source file, you'll usually see a transform-related error from Vite.
If Vite processed the module just fine but your code later throws while running in the browser, that's a runtime error. Then you're looking at browser execution or React's error reporting instead.
So before randomly changing things, first see whether Vite managed to produce valid JavaScript from your latest save.
What happens with npm package imports?
In React code we casually write stuff like this:
import { useState } from "react";But "react" isn't a relative file path.
There is no:
./react.jsnext to your component.
And browser doesn't independently go searching through node_modules trying to figure out what "react" refers to.
Vite handles that package resolution during development. It can also pre-bundle dependencies into modules that browser can load efficiently.
Vite 8 uses Rolldown in its production build and dependency optimization work, so this dependency processing is also part of the tooling you're running whenever you're developing the app.
You'll sometimes notice Vite doing dependency optimization again after changing installed packages.
For example:
npm install date-fnsIf Vite was already running before you installed that package and things start behaving strangely, restart the dev server.
npm run devDon't make restarting and deleting caches your answer to every bug though.
If JSX has a missing closing tag, restarting Vite twenty times isn't going to repair your JSX.
Dependency-related debugging makes sense when dependencies actually changed: you installed something, removed something, changed package versions, modified lockfiles, or changed how a package gets resolved.
Environment variables can require a restart too
Vite exposes client environment values through:
import.meta.envCustom values that should be available in browser code normally use the VITE_ prefix.
For example:
VITE_API_ORIGIN=https://api.example.comThen:
const apiOrigin = import.meta.env.VITE_API_ORIGIN;One very important thing to understand here: this is client-side data.
If you expose a value to browser code, users can see it. Putting SECRET_SUPER_PRIVATE_KEY inside a .env file doesn't magically make it private if you then expose it with a VITE_ name and bundle it into client JavaScript.
Don't put database passwords, private API keys, server credentials, or anything else secret inside a client-exposed VITE_ variable. If browser JavaScript can read the value, the user can read the value too.
Vite reads environment files when the dev server starts. So if you change one of those values, restart Vite and let it load the environment again.
This is different from editing App.jsx.
Changing component source can go through HMR. Changing startup configuration may require the process itself to restart.
localhost and that 5173 port
You'll normally see Vite print something similar to:
http://localhost:5173/Port 5173 is the normal default, but Vite can choose another port if that one is already being used.
So use the URL printed by the terminal you're currently running.
This sounds stupidly obvious until you have an old Vite process sitting on 5173, your new one starts on 5174, and you spend five minutes editing code while staring at the old app in another browser tab.
Been there.
The page is also running from the dev server's origin. If your React code starts making requests to another origin, normal browser cross-origin rules still apply.
Vite can proxy some development requests for you, but if the project doesn't even have an API yet, don't add a proxy just because you saw one in somebody's vite.config.js.
We can configure one when we actually need one.
Try these updates yourself
Take the counter from earlier and change its CSS color.
Save.
The new style should appear without throwing away the whole page. That's Vite HMR doing its work.
Then click the counter until it says 3 and change some text inside the component.
return (
<button onClick={() => setCount((n) => n + 1)}>
Lessons completed: {count}
</button>
);Save again.
With a normal compatible Fast Refresh update, you should still see 3.
Now manually reload the browser.
Back to zero.
Those three actions may all change something you see on screen, but they didn't follow the same process. CSS changed through HMR, React component source changed through HMR plus Fast Refresh, and browser reload threw away the current page and started the application again.
That last one is the result I always want to verify before calling the app okay.
When hot updates stop working
If somebody reports:
hot reload is broken
that's not really enough information to debug anything.
I want to know what they changed.
Was it a CSS file? A React component? Some config? An environment file?
What did the Vite terminal print after save? Did it say an HMR update happened? Did it invalidate some module? Was there a syntax or transform error?
Did browser keep the same document and update part of the app, or did you actually see a full reload?
Did component state survive?
And if you manually reload once, does the application become correct again?
Package versions are useful too:
npm ls vite
npm ls @vitejs/plugin-reactFrom there you've got actual information to work with.
"Hot reload doesn't work" can mean Vite never noticed the file, Vite couldn't process it, the module couldn't accept an HMR update, Fast Refresh reset the component, browser did a full reload, or the application rendered correctly and some completely separate bug made the result look stale.
So don't start by blaming React state.
First figure out what happened after the file was saved.