A Tale of Two Vues - Tips for Developing VueJS Libraries
Tags: vue, javascript, webdev
Published: 2021-12-14 14:32:07

A few weeks ago, I ran into an interesting problem. At [Superbet](https://superbet.engineering/), we were attempting to extract some [VueJS](https://vuejs.org/) reactive code into a separate utility library, using [TypeScript](https://www.typescriptlang.org/). I thought I knew what was waiting for us, and expected it to be a quick and simple thing. I was gravely mistaken. Vue [reactivity](https://vuejs.org/v2/guide/reactivity.html) broke, and investigating what happened was no easy task. However, it also involved a process of discovery that was interesting enough to write about!

In this article, I'd like to introduce a development process for external libraries that rely on Vue as a *[peer dependency](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#peerdependencies)*, warn you about the potential pitfalls, and share how it applies to other JavaScript ecosystems as well (such as [ReactJS](https://reactjs.org/)). I'll take you through the experiences we've had step by step, share the difficulties we've encountered, and help you avoid them.

## What We Thought Would Work

The task itself sounded simple enough - extract a number of utilities that make use of a [Vue observable](https://vuejs.org/v2/api/#Vue-observable) into a separate library, to be used across multiple Vue projects. We knew that we did not want to include the `vue` dependency into the library bundle itself, nor did we want it to be installed when you add the library. Doing this would increase the bundle size for no good reason, and could even lead to dependency version conflicts!

We attempted to resolve this by marking `vue` as a `peerDependency`. This is a type of dependency, specified in `package.json` under `peerDependencies`, that marks a special type of dependency that, at the same time, *are* and *are not* dependencies for the project. You can think of them simply as dependencies that are **expected** to be there when you're using the library, in the project that uses the library. The syntax is the same as for `dependencies` and `devDependencies` but, unlike those two, it needs to be added by manually modifying the `package.json` file. The specified [version range](https://github.com/npm/node-semver#versions) will signal which versions of that dependency are *compatible* with your library.

This pattern is essential for library development, especially when the code contained in the library is meant to be a plugin or an extension based on some behaviour provided by a core library. It avoids having the same dependency installed multiple times, or even with multiple versions, while still using version ranges to ensure compatibility. For example, a library that defined a Vue plugin that depends on Vuex being present might have the peer dependencies specified like this:

```json
{
  "peerDependencies": {
    "vue": "^2.6.0",
    "vuex": ">=3.5.1 <3.6.2"
  }
}
```

Of course, to develop and unit test your changes locally, you might still need to be able to import those dependencies, since there is no codebase to provide them for you. You can do this in one of three ways:

1. If you are using `npm` versions 1, 2, or 7+, this will be done for you automatically! 🎉
2. Otherwise, you can use a library such as [`npm-install-peers`](https://www.npmjs.com/package/npm-install-peers)
3. Or, even better, just add it as a `devDependency`!

Were this a simple JavaScript project without a build step, this would have been enough! If the code using this library as a dependency had these same dependencies in the correct versions, the library would make use of them instead of installing a separate version. If, instead, it did not have them, or had the wrong version, an error would be emitted during `npm install`.

## Fixing the Build Process

As you might have guessed, specifying it as a peer dependency was not sufficient! I hinted at this before - the build process was not considering the fact it was specified as a peer dependency, only that it was being imported into our codebase. This led to a *separate* instance of Vue being bundled with the library, and it was the root cause of my problems: **two Vue instances** and their observables are not mutually reactive. Not only did we double-bundle it and increase the package size, Vue (much like React) relies on there being a single instance of the library to work properly!

Luckily, the fix for that is straightforward enough - we just needed to tell the build tool to exclude those dependencies from the bundle. With [Webpack](https://webpack.js.org/), you can specify the [`externals`](https://webpack.js.org/configuration/externals/) field like so:

```jsx
module.exports = {
  externals: {
    vue: 'vue'
  },
}
```

[Rollup](https://rollupjs.org/guide/en/) has a similar mechanism for specifying external dependencies, like so:

```jsx
export default {
  // ...
  external: ['vue'],
  // ...
}
```

Alternatively, if you want Rollup to take care of those pesky peer dependencies for you, you can install a plugin for that. One such example is [`rollup-plugins-peer-deps-external`](https://www.npmjs.com/package/rollup-plugin-peer-deps-external). Add it to your project using your favourite package manager:

```bash
npm i -D rollup-plugin-peer-deps-external
# OR
yarn add -D rollup-plugin-peer-deps-external
```

After that's done, modify your rollup configuration:

```jsx
import external from 'rollup-plugin-peer-deps-external';

export default {
  // ...
  plugins: [
    external(), // preferably goes first
    // ...
  ],
};
```

After building and publishing the library, everything will work as expected! You can even go into the built files and check that the dependency (Vue, in our case) is not bundled! However, we would not consider publishing a new version of a library without testing it locally first, and this is where things got complicated once more...

## Testing Troubles

For most use cases, there is a simple and reliable flow for testing libraries without publishing them: we can use [`npm-link`](https://docs.npmjs.com/cli/v8/commands/npm-link) to connect a local version of a library, without having to update it on the [npm registry](https://www.npmjs.com/). The flow would be as follows:

```bash
# In your library folder
npm run build # or equivalent
npm link # for my-awesome-library

# In the folder of the app that uses the library
npm link my-awesome-library

## --------------------------------------------
## Alternatively, a single command to run from the app folder
npm link ../path-to/my-awesome-library
```

And that's it! When you build or run your project, it will be making use of the updated local artefacts, through the magic of [symlinks](https://en.wikipedia.org/wiki/Symbolic_link). 

That is to say, that would be it, unless you happen to be using peer dependencies and happen to be relying on a single instance of some object to exist in code, as happens to be the case with both VueJS and React. In this case, though the code would work fine if it were built and published, it will not resolve correctly with `npm-link`. There are a number of ways around it, some based on `yarn`, others specific to Webpack, or resolved by using [Lerna](https://lerna.js.org/). However, there are two fairly generic ways of handling it, as well.

The first is simpler, but more fragile. If the shared dependency is a single library, and the dependency graph is relatively simple, you can use `npm-link` to ensure they resolve to the same version of the dependency is resolved as the peer dependency, by running the following in your library folder:

```bash
# from my-awesome-library
npm link ../path-to/my-app/node_modules/vue
```

This works well enough for such a simple use case, but can be a pain to manage, and gets more complicated as the dependency graph gets messier. There is another, more robust way. Once you've set up your `peerDependencies` and your build system, and ensured that the built assets do not actually bundle the dependency, you can create a package locally, as a [tarball](https://whatis.techtarget.com/definition/tarball-tar-archive), and install it directly. This is essentially the same process as building and publishing the library, only using your computer as the repository. What you will need to do is as follows:

```bash
# in the library folder
npm run build # or equivalent
npm pack

# in the app directory
npm i --save ../path-to/my-awesome-lib/my-awesome-lib-1.2.3.tar.gz
```

And that is all there is to it! The dependency will be installed from the tarball, and you can now build or run your application and make sure everything works correctly.

⚠️ **NOTE:** This updates your `package.json` file in the application folder. Make sure you don't accidentally keep that change after you're done testing! The same goes for the tarball created in the library folder.

## Putting it All Together

Now you know all the essentials to start developing your own extensions and libraries that are based on Vue! To briefly recap what we need to know:

1. What are peer dependencies and how they are different than regular dependencies
2. What updates need to be done to your build system (if applicable) to avoid bundling the library twice
3. How to avoid the common `npm-link` pitfall

And that's all there is to it!

As an additional note, this rabbit hole goes much deeper than just Vue. As mentioned before, React also shares this issue. If you've been developing your own [React hooks library](https://reactjs.org/docs/hooks-custom.html), for example, you might have run into the now-legendary **[Hooks can only be called inside the body of a function component](https://reactjs.org/warnings/invalid-hook-call-warning.html)**, which is caused by the same core problem. You are definitely encouraged to share your own stories of similar issues in the comments, and propose other solutions to this problem that were not addressed by the article!