DEV Community

Omar Shehata
Omar Shehata

Posted on

Vite Hot Module Replacement - A Complete Example

This article explains how to set up hot module replacement (HMR) with Vite for a vanilla JS project. The goal is to be able to see changes in the code without refreshing or restarting the application.

For me this is particularly useful in creative coding when I'm iteratively building a simulation and don't want to lose state as I make live changes.

Complete source code for this article here: https://github.com/OmarShehata/vite-hot-reload-example.

Overview of my implementation

I'm going to show you how I've set up my HMR to make it as simple as possible to enable for new modules. In subsequent sections I'll break down how it works so you are aware of all the caveats and can use it in your project as needed.

To enable HMR on any module, I add the event handler at the top of the file:



import { HMREventHandler } from './HotModuleReloadSetup.js';

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


Enter fullscreen mode Exit fullscreen mode

This tells Vite to avoid refreshing the page when this module is updated, and instead fire an HMR event.

Next, I have a singleton instance of HotModuleReloadSetup that takes dynamically imported modules, and automatically swaps them out:



import HotModuleReloadSetup from './HotModuleReloadSetup.js';
// Setup HMR
const hmr = new HotModuleReloadSetup();
// Load a module that will be updated dynamically
hmr.import(await import('./Draw.js'));


Enter fullscreen mode Exit fullscreen mode

I now have access to an instance of the Draw class through hmr.instances['Draw'].

Finally, the draw class defines a hotReload handler. This passes a reference to the old instance so you can copy over any state variables to the new instance.

So when I call hmr.instances['Draw'].draw() in the render loop, it will always use the latest code.

This pattern is modelled after how PlayCanvas does their hot reloading.

How it works

Here is what happens when you make a change in an HMR module (Draw.js in my case) & save the file:

1 - Vite triggers an HMR event (which we've added a listener to via import.meta.hot.accept)
2 - I then dispatch a custom event on the DOM with that new module (this is in HotModuleReloadSetup.js):



export function HMREventHandler(newModule) {
    const event = new CustomEvent('hot-module-reload', { detail: { newModule } });
    document.body.dispatchEvent(event);
}


Enter fullscreen mode Exit fullscreen mode

This allows HotModuleReloadSetup to listen for these events globally.

3 - We search our map of modules to see if the new module we received exists
4 - If so, we create a new instance from the new module, call newInstance.hotReload(oldInstance), and discard the old module & old instance



swapModule(newModule) {
  const name = newModule.default.name;
  const oldModule = this.modules[name];
  const oldInstance = this.instances[name]
  if (!oldModule) return;

  const newInstance = new newModule.default();
  newInstance.hotReload(oldInstance)

  this.modules[name] = newModule
  this.instances[name] = newInstance
}


Enter fullscreen mode Exit fullscreen mode

This way you can access either one instance of a class that is always updated through the instances map, or you can access the latest module through the modules map. The latter can be used if your code is spawning many instances of a class (like enemies or bullets), and you want your live code changes to affect newly spawned instances.

Caveats

  • If you need to have multiple instances of a class that are all updated when the code changes, you'll need to keep an array of these instances and swap them all out in the same way HotModuleReloadSetup.swapModule does
  • If your HMR class needs constructor arguments, you'll need to store those in the global manager that creates the new instances so they can be passed to them as well.
  • I turn off Rollup minification because it rewrites function names, but cannot update all uses of the function when using dynamic imports like this. That causes hmr.instances['Draw'].draw() to break because the function name is no longer draw in a production build.
  • I import the module by name and pass it to hmr.import() due to the limitations on dynamic imports in Vite, see: https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations

Thanks for reading! I hope you found this useful. If you find a bug or a better way to streamline HMR, especially for creative coding, I'd love to hear. You can find me on Twitter: https://twitter.com/omar4ur.

Top comments (2)

Collapse
 
ekeijl profile image
Edwin

So if I understand correctly I need to enable HMR for every module manually? Seems like a lot of work.

Maybe it would save time if you're focussing on a single module that has a lot of internal state. Otherwise I would just setup Storybook and create stories for every individual state of the component.

Collapse
 
omar4ur profile image
Omar Shehata

I agree that Storybook sounds great for UI components. I think this model is less work for interactive/3D applications where parts aren't easily extracted out, and helpful if you need to see it in full context.