Hot Module Replacement

Hot Module Replacement (or HMR) is one of the most useful features offered by webpack. It allows all kinds of modules to be updated at runtime without the need for a full refresh. This page focuses on implementation while the concepts page gives more details on how it works and why it's useful.

Enabling HMR

This feature is great for productivity. All we need to do is update our webpack-dev-server configuration, and use webpack's built-in HMR plugin. We'll also drop the print.js tag from the page, as that module will now be consumed by index.js.

src/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
-    <title>Development</title>
-    <script src="./print.js"></script>
+    <title>Hot Module Replacement</title>
   </head>
   <body>
     <script src="./index.js"></script>
   </body>
 </html>

Since webpack-dev-server v4.0.0, Hot Module Replacement is enabled by default.

webpack.config.js

  import path from 'node:path';
  import { fileURLToPath } from 'node:url';

  const __filename = fileURLToPath(import.meta.url);
  const __dirname = path.dirname(__filename);

  export default {
    entry: './src/index.html',
    experiments: {
      html: true,
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
+     hot: true,
    },
    output: {
      filename: '[name].bundle.js',
      htmlFilename: '[name].html',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

you can also provide manual entry points for HMR:

webpack.config.js

  import path from 'node:path';
  import { fileURLToPath } from 'node:url';
+ import webpack from 'webpack';

  const __filename = fileURLToPath(import.meta.url);
  const __dirname = path.dirname(__filename);

  export default {
-   entry: './src/index.html',
+   entry: {
+     app: './src/index.html',
+     // Runtime code for hot module replacement
+     hot: 'webpack/hot/dev-server.js',
+     // Dev server client for web socket transport, hot and live reload logic
+     client: 'webpack-dev-server/client/index.js?hot=true&live-reload=true',
+   },
    experiments: {
      html: true,
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
+     // Dev server client for web socket transport, hot and live reload logic
+     hot: false,
+     client: false,
    },
+   plugins: [
+     // Plugin for hot module replacement
+     new webpack.HotModuleReplacementPlugin(),
+   ],
    output: {
      filename: '[name].bundle.js',
      htmlFilename: '[name].html',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

Now let's update the index.js file so that when a change inside print.js is detected we tell webpack to accept the updated module.

index.js

  import _ from 'lodash';
  import printMe from './print.js';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;

    element.appendChild(btn);

    return element;
  }

  document.body.appendChild(component());
+
+ if (module.hot) {
+   module.hot.accept('./print.js', function() {
+     console.log('Accepting the updated printMe module!');
+     printMe();
+   })
+ }

Start changing the console.log statement in print.js, and you should see the following output in the browser console (don't worry about that button.onclick = printMe output for now, we will also update that part later).

print.js

  export default function printMe() {
-   console.log('I get called from print.js!');
+   console.log('Updating print.js...');
  }

console

[HMR] Waiting for update signal from WDS...
main.js:4395 [WDS] Hot Module Replacement enabled.
+ 2main.js:4395 [WDS] App updated. Recompiling...
+ main.js:4395 [WDS] App hot update...
+ main.js:4330 [HMR] Checking for updates on the server...
+ main.js:10024 Accepting the updated printMe module!
+ 0.4b8ee77….hot-update.js:10 Updating print.js...
+ main.js:4330 [HMR] Updated modules:
+ main.js:4330 [HMR]  - 20

Via the Node.js API

When using Webpack Dev Server with the Node.js API, don't put the dev server options on the webpack configuration object. Instead, pass them as a second parameter upon creation. For example:

new WebpackDevServer(options, compiler)

To enable HMR, you also need to modify your webpack configuration object to include the HMR entry points. Here's a small example of how that might look:

dev-server.js

import path from "node:path";
import { fileURLToPath } from "node:url";
import webpack from "webpack";
import WebpackDevServer from "webpack-dev-server";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const config = {
  mode: "development",
  entry: [
    // Runtime code for hot module replacement
    "webpack/hot/dev-server.js",
    // Dev server client for web socket transport, hot and live reload logic
    "webpack-dev-server/client/index.js?hot=true&live-reload=true",
    // Your entry
    "./src/index.html",
  ],
  experiments: {
    html: true,
  },
  devtool: "inline-source-map",
  plugins: [
    // Plugin for hot module replacement
    new webpack.HotModuleReplacementPlugin(),
  ],
  output: {
    filename: "[name].bundle.js",
    htmlFilename: "[name].html",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
};
const compiler = webpack(config);

// `hot` and `client` options are disabled because we added them manually
const server = new WebpackDevServer({ hot: false, client: false }, compiler);

try {
  await server.start();
  console.log("dev server is running");
} catch (err) {
  throw new Error(`Failed to start dev server: ${err.message}`, { cause: err });
}

See the full documentation of webpack-dev-server Node.js API.

Gotchas

Hot Module Replacement can be tricky. To show this, let's go back to our working example. If you go ahead and click the button on the example page, you will realize the console is printing the old printMe function.

This is happening because the button's onclick event handler is still bound to the original printMe function.

To make this work with HMR we need to update that binding to the new printMe function using module.hot.accept:

index.js

  import _ from 'lodash';
  import printMe from './print.js';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;  // onclick event is bind to the original printMe function

    element.appendChild(btn);

    return element;
  }

- document.body.appendChild(component());
+ let element = component(); // Store the element to re-render on print.js changes
+ document.body.appendChild(element);

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('Accepting the updated printMe module!');
-     printMe();
+     document.body.removeChild(element);
+     element = component(); // Re-render the "component" to update the click handler
+     document.body.appendChild(element);
    })
  }

This is only one example, but there are many others that can easily trip people up. Luckily, there are a lot of loaders out there (some of which are mentioned below) that will make hot module replacement much easier.

What an update actually replaces

Most HMR confusion comes from one wrong assumption: that an update re-runs the module you wrote the accept call in. It does not.

When a module changes, webpack walks up from it through the modules that imported it until it finds one that accepted the change. Only the changed module is re-executed. The module that accepted it keeps running — the same instance, the same variables, the same closures — and its callback is invoked so it can react. accept('./dep.js', cb) therefore means "I will deal with ./dep.js changing", not "re-run me when it changes".

Two consequences follow, and they are the whole of HMR in practice:

  • The accepting module's dispose handler does not fire. Its handler runs only when that module is itself replaced, which happens when it changes or when it self-accepts.
  • Its imported bindings are already updated by the time the callback runs. Webpack rewires the import, so calling dep() inside the callback calls the new version — there is nothing to re-import.
import { greet } from "./service.js";

// this runs once; it is NOT re-run when service.js changes
console.log(greet());

if (import.meta.webpackHot) {
  import.meta.webpackHot.accept("./service.js", () => {
    // `greet` already points at the new version here
    console.log(greet());
  });
}

Self-accepting a module

A module that has no outward-visible bindings — it only causes effects when it runs — can accept its own changes. Webpack then re-executes that module in place and the update stops there instead of bubbling to the entry point.

import.meta.webpackHot.accept();

This is what stylesheets do, and it is the right choice for things like a registered route table, a set of chart definitions, or a <canvas> render loop. It is the wrong choice when other modules hold on to what this one exported: they captured the old value at import time, and re-running the module does not update their copies. Accept in the module that owns the reference instead.

Cleaning up side effects

Re-executing a module runs its side effects a second time. An interval gets scheduled twice, a listener is registered twice, a socket is opened again — after ten edits the page has ten of everything and looks "slow" or "flickery" for reasons that have nothing to do with webpack.

dispose is the release valve. It runs before the new version executes, so it is where you undo what the current version claimed:

const socket = new WebSocket(url);
const timer = setInterval(poll, 1000);
const onResize = () => layout();

window.addEventListener("resize", onResize);

if (import.meta.webpackHot) {
  import.meta.webpackHot.accept();
  import.meta.webpackHot.dispose(() => {
    socket.close();
    clearInterval(timer);
    window.removeEventListener("resize", onResize);
  });
}

The rule of thumb: anything that outlives the module's own evaluation — timers, listeners, sockets, observers, DOM nodes appended to document, entries added to a global registry — needs a matching line in dispose.

Preserving state across an update

The data object passed to dispose is handed to the next version of the module as import.meta.webpackHot.data, which is undefined on the first run. That round trip is how an edit keeps the state it should keep — a scroll position, a form draft, a game's score, the frame counter of an animation:

const previous = import.meta.webpackHot && import.meta.webpackHot.data;
let ticks = previous ? previous.ticks : 0;

const timer = setInterval(() => {
  ticks += 1;
  render(ticks);
}, 1000);

if (import.meta.webpackHot) {
  import.meta.webpackHot.accept();
  import.meta.webpackHot.dispose((data) => {
    clearInterval(timer);
    data.ticks = ticks;
  });
}

Edit this module and the counter carries on from where it was instead of restarting at zero — dispose stops the old interval and stashes the value, then the new version reads it back out of data.

Hot-swapping a singleton

A store, a router, a DI container: something created once that the whole application holds a reference to. Replacing the object itself would strand every holder on the old one, so replace its contents instead and leave the identity alone. A Redux store is the familiar version — replaceReducer exists for exactly this:

import { createStore } from "redux";
import rootReducer from "./reducers/index.js";

const store = createStore(rootReducer);

if (import.meta.webpackHot) {
  import.meta.webpackHot.accept("./reducers/index.js", () => {
    store.replaceReducer(rootReducer);
  });
}

export default store;

The store keeps its state and its subscribers; only the reducer function changes. Any singleton with a "swap the implementation" method — a router's route table, a registry's entries, an event bus's handlers — follows the same shape.

When an update can't be applied

If no module up the chain accepts a change, the update is aborted. webpack-dev-server responds by reloading the page, so a full reload during development usually means "nothing accepted this file", not a broken setup.

Two ways to steer that deliberately:

  • decline marks a module as never hot-updatable, forcing the reload immediately rather than after a failed attempt. Useful for a module whose side effects genuinely cannot be undone.
  • invalidate is for the conditional case: you accepted a dependency, but this particular change is one your callback cannot handle, so you hand the update to your parent instead.
import.meta.webpackHot.accept("./config.js", () => {
  if (config.port !== runningPort) {
    // a port change needs a restart; let it bubble
    import.meta.webpackHot.invalidate();
    return;
  }
  applyConfig(config);
});

HMR on the server

HMR is not browser-only — it works under target: 'node', and is how a long-running server swaps request handlers without dropping its listening socket or in-memory state. There is no dev-server client here, so the bundle asks for updates itself, driven by a watching compiler that writes the update files:

import express from "express";
import handler from "./handler.js";

const app = express();
let current = handler;

// the indirection is what makes the swap possible:
// express keeps this closure, and the closure reads `current`
app.use((req, res, next) => current(req, res, next));

const server = app.listen(3000);

if (import.meta.webpackHot) {
  import.meta.webpackHot.accept("./handler.js", () => {
    current = handler;
  });
  import.meta.webpackHot.dispose(() => server.close());

  setInterval(() => {
    if (import.meta.webpackHot.status() === "idle") {
      import.meta.webpackHot.check(true).catch((err) => {
        console.error("HMR update failed, restart the server:", err.message);
      });
    }
  }, 1000);
}

check(true) downloads the update and applies it, resolving with the replaced modules — or with null when there is nothing new. It rejects when the update could not be applied, with a message naming the file, e.g. Aborted because ./handler.js is not accepted. On a server that is your cue to restart the process, since there is no page to reload.

HMR with Stylesheets

Hot Module Replacement with CSS needs no setup at all: experiments.css defaults to 'auto', so webpack handles stylesheets itself and patches them in place when they change — no loader to install, no rule to add. See Native CSS for what the built-in support covers.

Hot loading stylesheets can be done by importing them into a module:

project

  webpack-demo
  ├── package.json
  ├── webpack.config.js
  ├── /dist
  │   └── bundle.js
  └── /src
      ├── index.html
      ├── index.js
      ├── print.js
+     └── styles.css

styles.css

body {
  background: blue;
}

index.js

  import _ from 'lodash';
  import printMe from './print.js';
+ import './styles.css';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;  // onclick event is bind to the original printMe function

    element.appendChild(btn);

    return element;
  }

  let element = component();
  document.body.appendChild(element);

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('Accepting the updated printMe module!');
      document.body.removeChild(element);
      element = component(); // Re-render the "component" to update the click handler
      document.body.appendChild(element);
    })
  }

Change the style on body to background: red; and you should immediately see the page's background color change without a full refresh.

styles.css

  body {
-   background: blue;
+   background: red;
  }

Other Code and Frameworks

There are many other loaders and examples out in the community to make HMR interact smoothly with a variety of frameworks and libraries...

  • React Fast Refresh: Tweak React components in real time, preserving their state. It replaces React Hot Loader, which is deprecated and no longer maintained.
  • Vue Loader: This loader supports HMR for vue components out of the box.
  • Elm Hot webpack Loader: Supports HMR for the Elm programming language.
  • Angular HMR: No loader necessary! HMR support is built in the Angular CLI, add the --hmr flag to you ng serve command.
  • Svelte Loader: This loader supports HMR for Svelte components out of the box.
Edit this page·
« Previous
Installation

20 Contributors

jmreidyjhnnssararubinrohannairjoshsantosdrpicoxskipjacksbaidongdi2290bdwaincarylixgirmaEugeneHlushkoAnayaDesignaviyacohendhruvduttwizardofhogwartsaholznersnitin315Brennvo