Logo provash.dev

27.03.2023

How to avoid circular dependency in your Vite applications for proper HMR

A diragram depicting a circular dependency

When Vite came out to production, I knew I had to give it a try and I was definitely impressed! We immediately opted it for one of our projects in our company. Having a well-configured webpack is challenging and anytime we needed to add some extra capabilities like adding sass library, proper svg imports, adding typescript support etc., it requires some manual fight with webpack to set things up properly. That’s why when I discovered Vite, switching to it was a no-brainer.

At the beginning, everything was great. We had a smooth development workflow and the fast sever boot and HMR was awesome. Everyone on the team was impressed and pretty happy with adoption of Vite. But soon, we discovered some weird behavior with HMR. Somehow, it was causing a complete reload of the page when some of the files were updated.

Since we use Typescript as our default language, it means we have a lot of interface / definition files where we define our classes / data-models. Like header files when you work with the C / C++ programming language. It usually gives a nice abstraction between the actual implementation of the class and the definition which can be reused in multiple parts of the codebase. We noticed that, when we change anything in these classes or definition files, HMR of Vite breaks and causes a complete reload of the application.

My first thought was, maybe something was wrong with typescript+vite configuration. Because HMR was completely fine when changing any React component/views, so basically *.tsx files were fine. It was only causing issues when changing the *.ts files. I tinkered around typescript and some Vite configuration, but eventually, it didn’t worked. After a good amount of googling and talking a careful look into the codebase, I discovered, the actual issue was circular dependencies.

Now, the way those definition files were defined, some of them were dependent on each other. For example, our IUser.ts interface imported ISettings.ts interface. Inside interface ISettings we needed to import language preference of the user from ILanguage.ts. This is fine till now, but here comes the trouble. From ILanguage we needed reference back to IUser for some of the properties (good old id), so it also imported IUser.ts. Thus, creating a dependency loop.

IUser.ts -> ISettings.ts -> Language.ts -> IUser.ts

A dependency loop is usually not an issue when developing in backend, since Typescript bundler is smart enough to notice and package them once. Vite is also not running into any infinite loop, only the HMR is not working properly. The documentation of Vite also mention this:

If there is a dependency loop, a full reload will happen. To solve this, try removing the loop. — Vite documentation

The way our codebase is structured, it’s difficult to remove all those circular dependencies due to references between different definition files. You might say it’s a bad practice, but this makes more sense than making further abstraction of common properties between definitions and we wanted to keep it that way.

import type to the rescue!

Typescript 3.8 added a new syntax import type which only imports the file for pure type declaration and annotations purpose. It gets removed completely in compilation time. You can learn more about it in the documentation. There is also a typescript config prop called importsNotUsedAsValues which can be use to make sure all the imports that are purely for type definition, should be imported as import type. The default value of this prop is remove which removes all the type import statements. But this won’t error out in compilation. We choose error so that we can clearly notice that a specific definition file should be imported with import type inside our IDE as well as in the compilation time.

Now Vite also ignores these import type statements and don’t try include them in HMR process. Which is a big help, since most of our circular dependencies were related to type imports. We refactored our codebase so that in all places where we imported pure type definitions, we use the import type statement. VSCode IDE also helped a lot here by showing error messages. Afterward, a lot of our HMR page-reload issues were resolved.

Now you can say, hey Provash, I understand your problem regarding definition imports, but in my project, I don’t use typescript. But I also have some circular dependencies, how can I detect and resolve them?

This was also the case for us in some react modules we later discovered. We use MobX for state management for better experience with Typescript than redux due to its class style store definition. We had some circular dependencies between “store imports” and some “view layer” components. We had to remove them manually and abstract away the common dependencies to separate files. But the question was, how to detect where we have a circular dependency in a large codebase? Clearly, trying to manually investigate each file, is not a feasible solution and also we needed a way to make sure that we don’t accidentally introduce new circular dependencies during development.

madge to the rescue!

Madge is a tool to create visual graph of the dependencies in your application. It will traverse though all files that is linked to the target file and will generate a graph for code and dependencies. You can run the tool by requiring it from a *.js|ts[x] file, or you can also run it via command line with npx after installing it as a local dependency for your project. Fortunately, the tool also has the capability of detecting any circular dependencies in a codebase. It also supports typescript and we used this tool to get rid of the rest of circular dependency issues in our codebase.

After installing the tool via npm, we needed to add a custom config in .madgerc file since we use Typescript and want to ignore import type statements. You can ignore this if you’re not using Typescript.

{
  "detectiveOptions": {
    "ts": {
      "skipTypeImports": true
    }
  }
}

Then, you can run the following command to check for any circular dependencies:

npx --no-install madge --circular src/App.tsx

Here as a target entry file, we telling Madge to detect any circular dependencies from src/App.tsx. It will recursively traverse though all files that is imported from this entry point and will throw an error if it can detect any circular dependencies. Throwing an error part is important, because we also want to include this in our development workflow and we need to it to fail if it detects any issues.

We also use Git Hooks with Husky to perform some linting and sanitary checks on our code before it gets committed. We also use GitHub Actions to run lints, tests, builds and deployment to cloud. I added the same code above in both commit hook step and CI/CD step during lint check. It is a bit redundant but necessary since it’s possible to accidentally commit code with flaws bypassing the commit hooks. So it will fail even if somehow code ends up in the repository.

Conclusion

That was a bit long reading, but thanks for reading till the end. By using import type and madge, we were able to completely get rid of circular dependencies form our codebase and it’s now a feasible solution for large codebase with commit hooks and check during CI/CD step. Hopefully it will also help you to reduce circular dependency in your codebase and have a better development workflow with Vite and it’s awesome HMR support. 🎉