---
title: "Hot module replacement"
description: "How the pitlane/vite-plugin-remix Vite plugin hot-updates a Remix 3 app during vite dev, covering which component edits swap in place, which remount, how server-only edits revalidate through the frame runtime, and the requirements and limits of both halves."
url: "https://pitlane.tools/guides/hmr"
---

# Hot module replacement

`vite dev` updates a running app in place. Editing a component swaps its new code in without remounting, so hydrated islands keep their open menus, input values, and counters. Editing a server-only module refetches the current page through your fetch handler and reconciles the new HTML into the DOM, which keeps that same island state while the server output changes underneath it.

Component HMR needs no configuration. Server-data revalidation needs a few lines in your browser entry, described under [Setup](#setup). Both are development-only. Component transforms run only on a dev server with HMR on. A test runner such as Vitest serves modules with `server.hmr` off, so your components reach your tests untouched. A production build removes the browser-entry listener guarded by `import.meta.hot`.

## What each kind of edit does

| You edit | What happens | What keeps its state |
| --- | --- | --- |
| The render function of an HMR-compatible component | Its new code is swapped in place | Every island, including this one |
| The setup scope of an HMR-compatible component | That component remounts | Every other island |
| A component export the transform skips | The page refetches and reconciles | Every other island |
| A server-only module | The page refetches and reconciles | Every island |
| A non-component module the browser imports | The importing island updates, output can go stale | Every island (see [limits](#limits)) |
| CSS | Vite's own CSS handling | Every island |
| Which stylesheets a server module imports | The page refetches with the new stylesheet links | Every island |

No row in that table is a full page reload.

## Setup

Keep the runtime that `run()` returns in your browser entry, and pass it to `revalidate` from `pitlane/vite-plugin-remix/hmr` when the plugin reports a server update:

```ts title="app/entry.browser.ts"
import { revalidate } from "pitlane/vite-plugin-remix/hmr";
import { run } from "remix/component";

let app = run({
    async loadModule(moduleUrl, exportName) {
        let mod = await import(/* @vite-ignore */ moduleUrl);
        return mod[exportName];
    },
});

if (import.meta.hot) {
    import.meta.hot.on("server:update", () => revalidate(app));
}
```

Pass `run()` the same options you already do. Only the captured `app`, the import, and the `import.meta.hot` block are new.

`revalidate` waits on `app.ready()`, so an update that lands during initial hydration does not reload a frame that is still being adopted. It then reloads the top frame, which refetches the page through your fetch handler and reconciles it in place. A failed reload is logged, and the next server edit tries again.

Keep the listener as written, without `async` or `await`. Vite handles HMR messages one at a time and waits for any promise a listener returns. A listener that awaited the reload would hold back every later HMR message until that reload settled, including the next server update. `revalidate` returns nothing for the same reason.

No environment guard is needed beyond `import.meta.hot`. A production build replaces it with `undefined`, so the whole block, the event name included, is removed from the client bundle. `import.meta.hot` is typed by `vite/client`, which a Vite project's `vite-env.d.ts` already references.

Component HMR is independent of this listener and runs whether or not you add it. Without it, server-only edits reach the server and the page does not change until you reload. The same holds on any page that never loads the module holding the listener, so keep `run()` and the listener in the entry every page loads, not in a module that only island pages import.

`@pitlane/dev` 0.7 and earlier provided this listener as an `<HMR />` component imported from `pitlane:dev`. That module is gone: remove the import and the `<HMR />` element from your document, and add the block above to your browser entry. An app that used the helper from `pitlane/dev/hmr` or `@pitlane/dev/hmr` imports the same `revalidate` from `pitlane/vite-plugin-remix/hmr` or `@pitlane/vite-plugin-remix/hmr`.

## Component HMR

Component edits run through the [`remix/component-hmr`](https://github.com/remix-run/remix/tree/main/packages/component-hmr) transforms. The browser transform runs in the client environment and the server transform in your server environments, and both emit the standard `import.meta.hot.accept()` protocol that Vite's own HMR runtime drives. The `pitlane/vite-plugin-remix` plugin supplies the wiring and one transform of its own, described below.

### Which exports are boundaries

An exported symbol is injected with the HMR runtime when all of these hold (i.e. when it is "HMR-compatible"):

- **The file ends in `.tsx` or `.jsx`** and does not live under `node_modules`. A component in a `.ts` file is never injected with the HMR runtime.
- **The export is named and top level.** Default exports are skipped, because the hydration protocol needs an export name.
- **The name starts with a capital letter.** `Counter` qualifies, `counter` does not.
- **The setup function returns a render function**, or the export is a `clientEntry(import.meta.url, setup)` call.

All four of these authoring styles qualify:

```tsx
export const Counter = clientEntry(import.meta.url, handle => {
    /* ... */
});
export const Toggle = clientEntry(import.meta.url, function Toggle(handle) {
    /* ... */
});
export const Card = handle => () => <div />;
export function Panel(handle) {
    /* ... */
}
```

`remix/component-hmr` only recognizes a setup function that carries a name. To allow you to define your components using arrow functions, the `pitlane/vite-plugin-remix` plugin rewrites qualifying arrow exports before `remix/component-hmr` sees them. `export const Counter = clientEntry(url, handle => …)` becomes `export const Counter = clientEntry(url, function Counter(handle) { … })` in the dev transform only. Setup functions never rely on a lexical `this` or `arguments`, so the rewrite is behavior-identical. If `remix/component-hmr` declines to inject the component with the HMR runtime, the rewrite is discarded, which leaves non-component arrow functions untouched.

### State survives a render edit and resets on a setup edit

This is the line that decides whether your counter keeps counting. `remix/component-hmr` hashes the setup scope, meaning the source from the start of the setup body up to its `return`. While that hash stays the same, the new render function replaces the old one and the live component keeps its state. Change it and the component is marked stale, then remounts.

```tsx
export const Counter = clientEntry(import.meta.url, handle => {
    let count = 0; // setup scope: editing this line remounts and count returns to 0
    let step = 1;

    function increment() {
        // still setup scope
        count += step;
        handle.update();
    }

    return () => (
        // everything below swaps in place, count intact
        <button mix={[on("click", increment)]}>
            Count: <span>{count}</span>
        </button>
    );
});
```

Iterating on markup and styles keeps the count. Changing how state is declared resets it, which is the correct outcome, because the new setup scope describes different state.

### Exports the transform skips

An export that misses one of the four conditions is left alone, and nothing is logged. The edit still reaches the server, so the server-data half picks it up. The page refetches and the DOM updates, other islands keep their state, and the edited component remounts. The change is never lost. It costs that one component's state.

To make such a component hold its state across edits, check it against the four conditions above.

## Server-data HMR

Editing the document, a middleware, a route handler, a data module, or anything else the browser never loads refetches the current page and reconciles it. Hydrated island state survives. This is the Remix 3 analog of React Router's loader and action revalidation, driven through the frame runtime rather than a client data router.

### How a server edit reaches the browser

1. The changed file is classified in your server environment. A file counts as server-only when the client module graph does not serve it as a script.
2. The plugin broadcasts a `server:update` event to the browser.
3. The listener in your browser entry receives the event and calls `revalidate(app)`, which reloads the top frame of the runtime `run()` returned.
4. The frame runtime refetches the page through your fetch handler and reconciles the new HTML in place.

The browser entry is where the app's runtime lives, which is what gives step 3 the top frame. Reloading the frame directly is what revalidation means here. It produces no history entry and fires no `navigate` event, so an app that intercepts navigation itself keeps working, and a listener watching for real navigations never sees dev traffic.

### What counts as server-only

The classification asks one question per changed file: does the client module graph serve this file as a script? Only script modules count. Plugins that scan your sources for their own reasons register other node types for ordinary server files, and Tailwind's content scanner is the common case: it registers an asset node for every file it scans. Counting those would mark every server module as client-owned and switch server-data HMR off for the whole app.

Files ending in `.ts`, `.tsx`, `.js`, and `.jsx` are considered. Other file types are left to the plugins that own them.

### Bursts collapse into one refetch

The broadcast waits 50ms. Two things fall out of that. A save that touches several files refetches _once_ instead of _once per file_. And the request cannot reach your fetch handler while Vite is still applying the update to the server environment, which on slower runtimes (such as workerd) can produce an error in dev mode.

A newer update supersedes one still in flight. The frame runtime abandons the older refetch, and the page shows the output of the latest server edit.

## Requirements

| Requirement | Why | When unmet |
| --- | --- | --- |
| The server-update listener in your browser entry | It is the browser half, and it holds the runtime whose top frame it reloads | Server edits reach the server and the page does not change |
| A client entry file (e.g. `entry.browser.ts`) that the document loads | The listener lives there, next to `run()` | Nothing receives the event and revalidation never runs |
| `serverEnvironments` matches your config | It selects which environment classifies files as server-only | Neither half can tell client from server |

If a platform plugin renames the server environment, pass the same names to [`remix({ serverEnvironments })`](https://pitlane.tools/package/vite-plugin-remix/interface/RemixPluginOptions.md#serverenvironments). `@cloudflare/vite-plugin` with `viteEnvironment: { name: "ssr" }` matches the default and needs nothing. This is the same option island discovery uses, so a mismatch shows up as broken hydration too.

Nothing here depends on navigation, so an app that installs its own `navigate` listener and stops Remix from seeing navigations still revalidates normally.

### Fully server-rendered apps

`clientEntry: false` turns off the client environment, so nothing calls `run()`, nothing hydrates, and no script tag reaches the browser. There is no browser entry to hold the listener, so server edits do not revalidate the page.

To serve no browser JavaScript in production and still revalidate in dev, keep the client entry and gate the script tag instead:

```tsx
{
    import.meta.env.DEV && <script async src={scriptEntry.href} type="module" />;
}
```

Production drops the tag and serves zero JavaScript, since the hydration markers are inert HTML on their own. Dev keeps the full client runtime, so both halves of HMR work. The production build still emits an unused client chunk under `dist/client`. Dev also carries a client runtime that production does not, so links soft-navigate through the frame runtime in dev while production loads whole documents.

### Client-rendered apps

`remix({ server: false })` is the mirror image. Everything renders in the browser, so component HMR is the whole story and there is no server data to revalidate. The plugin never broadcasts a server update, so the browser entry needs no listener, and the requirements table above does not apply. The client entry is whatever `index.html` loads. There are no server environments to name.

This is also the only mode that currently works under Vite's experimental bundled dev mode, component hot-swap included. See [Single-page apps](https://pitlane.tools/guides/spa.md) for the whole mode.

## Limits

**A shared module the browser imports can render stale output.** Editing a non-component module that a hydrated island imports (a constants file or a formatting helper) updates the island's boundary and keeps its state. The rendered output can keep showing the previous value, including after an interaction re-renders the component. Reload to pick it up. Component modules and server-only modules are the two reliable boundaries.

**The first edit after a dependency change can be missed.** Installing or re-pinning a dependency makes Vite rebuild its client dependency cache. The first edit after that reaches the server but not the browser. One reload settles it. The cause is Vite's dependency prebundling rather than the plugin.

**Production is untouched.** Both transforms are dev-only, so no wrapper indirection and no runtime imports reach a build, and the browser-entry listener is removed with its `import.meta.hot` branch. Nothing about HMR changes `vite build` output.

## How this maps to Remix's HMR packages

[`remix/component-hmr`](https://github.com/remix-run/remix/tree/main/packages/component-hmr) provides the component transforms plus the browser and server runtimes. The `pitlane/vite-plugin-remix` plugin runs those transforms and owns the server-data half. The arrow normalization is its own addition, so that arrow exports qualify for instrumentation.

[`remix/node-hmr`](https://github.com/remix-run/remix/tree/main/packages/node-hmr) is deliberately not used. It supervises a child Node process and provides `import.meta.hot` through Node's module customization hooks, which is the job Vite's module runner already does here. Its fetch-proxy and restart behavior would duplicate the dev server rather than add to it.
